From e53579cb5c5d32705ed2c786159cd6cc6555fe2f Mon Sep 17 00:00:00 2001 From: sak0a Date: Fri, 2 Oct 2026 07:56:54 +0200 Subject: [PATCH 1/3] Verify GC request lifecycles and publish explicit feature support for v2.6.0 --- .github/workflows/ci.yml | 32 + .gitignore | 3 +- CHANGELOG.md | 22 + FEATURE_SUPPORT.md | 131 ++ README.md | 12 + RELEASE_NOTES.md | 22 + bun.lock | 41 +- fixtures/README.md | 50 + fixtures/inspect-corpus.json | 1813 ++++++++++++++++++++++++++++ handlers.js | 109 +- index.js | 994 +++++---------- lib/proto-decode.js | 2 + lib/request.js | 72 ++ lib/wire-schema.json | 1 + lib/wire-validation.js | 61 + package.json | 21 +- scripts/generate-inspect-corpus.py | 86 ++ scripts/generate-response-types.js | 53 + scripts/generate-wire-schema.js | 42 + scripts/live-test.js | 195 +++ scripts/verify-consumers.js | 54 + test/corpus.test.mjs | 39 + test/helpers/fake-steam.mjs | 39 + test/lifecycle.test.mjs | 259 ++++ test/live-runner.test.mjs | 76 ++ test/payloads.test.mjs | 109 ++ test/types/tsconfig.json | 10 + test/types/usage.ts | 32 + types/index.d.ts | 92 +- types/responses.d.ts | 363 ++++++ 30 files changed, 4082 insertions(+), 753 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 FEATURE_SUPPORT.md create mode 100644 fixtures/README.md create mode 100644 fixtures/inspect-corpus.json create mode 100644 lib/request.js create mode 100644 lib/wire-schema.json create mode 100644 lib/wire-validation.js create mode 100644 scripts/generate-inspect-corpus.py create mode 100644 scripts/generate-response-types.js create mode 100644 scripts/generate-wire-schema.js create mode 100644 scripts/live-test.js create mode 100644 scripts/verify-consumers.js create mode 100644 test/corpus.test.mjs create mode 100644 test/helpers/fake-steam.mjs create mode 100644 test/lifecycle.test.mjs create mode 100644 test/live-runner.test.mjs create mode 100644 test/payloads.test.mjs create mode 100644 test/types/tsconfig.json create mode 100644 test/types/usage.ts create mode 100644 types/responses.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..19adbef --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI +on: + pull_request: + push: + branches: [master, main] +permissions: + contents: read +jobs: + offline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.0 + - run: bun install --frozen-lockfile + - run: npm run lint + - run: npm test + - run: npm run test:types + - run: npm run generate-wire-schema && npm run generate-response-types + - run: git diff --exit-code -- lib/wire-schema.json types/responses.d.ts + packed-consumers: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: npm run test:consumer diff --git a/.gitignore b/.gitignore index 3c0ad91..3d16abd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ -node_modules/* -test/* +node_modules/ # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 diff --git a/CHANGELOG.md b/CHANGELOG.md index 69ba1e6..96b9c2b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## v2.6.0 - Request lifecycle and verification + +- Added a reusable fake Steam transport, independent outgoing/incoming wire fixtures, + and a shared randomized inspect corpus. New tests are no longer hidden by the test-directory ignore rule. +- Centralized request cleanup and same-key serialization; added `cancelPendingRequests()`, + `dispose()`, and `RequestError` codes with mutation uncertainty. Failed sent keys remain + quarantined for the client lifetime and are never automatically retried. +- Fixed subscription-after-send races, wrong notification correlation, discarded additional + profiles, absent wear coerced to zero, duplicate/reordered inventory changes, unsigned + storage-unit IDs, and malformed nested protobuf/craft payload handling. +- Added schema-generated public response/event types and compile-time consumer examples. + Corrected Promise overload inference and the numeric `ItemInfo.accountid` declaration. +- Added an explicit [feature support matrix](FEATURE_SUPPORT.md) and a safe, opt-in + authenticated live runner with individually designated mutations and inventory assertions. +- Added packed consumer CI across Node 14/16/18/20/22/24 and steam-user 4.2.0/4.29.3/5.3.0. + +Migration: existing calls remain compatible. Handle request rejections, reconcile uncertain +mutations before any manual retry, and dispose unused clients. Read FEATURE_SUPPORT.md +for quarantine semantics and callback differences. Live Steam/GC behavior remains unverified; +schema-correct sends and offline notifications do not establish server-side feature support. + + This changelog tracks `node-cs2` releases. Dates through `2.2.4` use npm publish timestamps; `2.3.0` is the prepared package version in this repository. Legacy git tags from the upstream `globaloffensive` package are intentionally omitted when they were not published as `node-cs2`. diff --git a/FEATURE_SUPPORT.md b/FEATURE_SUPPORT.md new file mode 100644 index 0000000..767997c --- /dev/null +++ b/FEATURE_SUPPORT.md @@ -0,0 +1,131 @@ +# Feature support and verification + +Evidence date: **2026-10-02**. Protocol snapshot: SteamDatabase/GameTracking-CS2 +`3fc98e763328f7d1627405b389d1b6b69c5b0e38` (2026-09-25), checked into `protobufs/`. + +“Payload” means actual transport bytes agree with separately written wire fixtures. +“Decode” means independent incoming wire bytes reach the public event/result. +“Lifecycle” means an offline fake Steam transport exercises real dispatch and cleanup; +it does **not** establish server acceptance. “Partial” lists the tested subset. +No authenticated live evidence or sanitized account capture was available for this release. +The fixture corpus is synthetic and independently encoded; it is not described as a Steam replay. + +| Feature / public API | Outgoing payload | Response decoding | Offline lifecycle | Live behavior / prerequisites | +|---|---|---|---|---| +| Legacy `inspectItem` | Verified | Verified | Concurrent IDs, same-ID queue, timeout, late/duplicate replies, cancel, disconnect, callback/Promise | Unverified; logged-in account, GC session, valid inspect link | +| Embedded `inspectItem` | Local; no send | Verified: masked/unmasked, uint64, float32, Unicode, repeated names, opaque bytes, unknown fields | Callback/Promise parity | Local operation; Steam not required | +| `requestPlayersProfile` | Verified | Verified, including multiple profiles | Success, callback/Promise timeout cleanup | Unverified; GC session, valid profile | +| `requestRecurringMissionSchedule` | Verified | Verified | Shared request lifecycle; response dispatch | Unverified; account/region mission availability | +| `getCasketContents` | Verified | Customization notification verified | Cached/empty behavior retained; shared request lifecycle | Unverified; owned storage unit and accessible contents | +| `loadVolatileItemContents` | Verified | Assumes `CasketContents` notification | Synchronous response, unrelated notification rejection, shared item queue | Unverified; owned volatile item. Notification association remains a hypothesis | +| `extractSticker`, `encapsulateSticker` | Verified against schema | Notification item/type decoding verified | Same-item serialization, wrong item/type, duplicate reply, cleanup | Unverified; eligible sticker/item, any required tool. Notification association not live-proven | +| `applyPatch`, `removePatch`, `applyKeychain` | Verified against schema | Notification item/type decoding verified | Same-item serialization, mixed operations, timeout uncertainty, callback/Promise parity | Unverified; eligible designated items/slots. Notification association not live-proven | +| `removeKeychain` | Verified | No response awaited | Fire-and-forget only | Unverified; attached charm and applicable removal prerequisites | +| `openCrate` | Verified, including explicit zero/false | Notification decoder verified; subject-ID association assumed | Shared item queue; existing callback behavior tested | Unverified; owned crate/key, account eligibility. A response that omits the subject ID will time out | +| `redeemFreeReward`, `claimVolatileItemReward` | Verified | Free-reward notification decoded | Shared global reward lane; no request ID; sequential replies | Unverified; eligible reward/balance; volatile-to-free-reward association assumed | +| `redeemMissionReward` | Verified | Customization decoder verified | Shared mission-reward lane | Unverified; eligible mission/cost/balance | +| Inventory create/update/destroy/multiple | Incoming only | Verified | Duplicate events, reordered versions, tombstones, truncated attributes | Unverified; live inventory cache traffic | +| `requestGame`, `requestLiveGames`, `requestRecentGames`, `requestLiveGameForUser` | Verified | Match-list event verified | Event-only; no request Promise | Unverified; valid sharecode/user, available matches | +| `nameItem`, `deleteItem`, `craft`, `addToCasket`, `removeFromCasket` | Verified | Craft parser rejects truncation; inventory decoder verified | Fire-and-forget; no operation success assertion | Unverified; owned eligible items/tools; destructive operations excluded from runner | +| `commendPlayer`, `setLeaderboardSafeName`, `acknowledgeRentalExpiration`, `acknowledgeXPShopTracks`, `ackPetEvent` | Verified | No operation response awaited | Fire-and-forget only | Unverified; relevant account, item, or match prerequisites | +| Account data, XP shop, mission, match, search-stat events | Incoming only | Verified with independent bytes | Event dispatch | Unverified; event-specific account activity | +| Premier season summary | Incoming only | Schema-derived type; not independently fixture-verified | Existing handler only | Unverified; eligible Premier season history | +| GC connect/reconnect | Existing hello encoding; not independently verified | Welcome/status parser exercised | Timer teardown, reconnect scheduling, disposal | Unverified; Steam and GC availability | + +Evidence is in `test/payloads.test.mjs`, `test/lifecycle.test.mjs`, +`test/corpus.test.mjs`, `test/live-runner.test.mjs`, `test/types/usage.ts`, and +`scripts/verify-consumers.js`. CI installs packed artifacts in separate consumer +projects and exercises real steam-user 4.2.0, 4.29.3, and 5.3.0 constructors on +Node 14, 16, 18, 20, 22, and 24. Runtime support does not imply upstream security +maintenance of old Node versions. Test/lint tooling itself requires Node 24. + +## Lifecycle and migration notes + +Existing callback and Promise call signatures remain supported. Inspect callbacks +retain their historical success-only signature; use `inspectItemTimedOut` for their +timeout event, or use the Promise overload for structured failure handling. Profile +callbacks continue receiving either a profile or an Error. Mutation callbacks remain +error-first. Promise overload declarations now correctly infer Promise return types. + +`cancelPendingRequests()` rejects active and queued requests and clears their timers +and listeners. `dispose()` also permanently removes this instance's Steam transport +listeners. Neither method retries a request. External event listeners are preserved. + +Promise rejections and error-first callbacks use `NodeCS2.RequestError` with `code` +(`TIMEOUT`, `CANCELLED`, `DISCONNECTED`, `DISPOSED`, `NOT_CONNECTED`, `SEND_FAILED`, +`UNCERTAIN_PREVIOUS_RESULT`) and `uncertain`. A sent mutation that times out or loses +its connection may already have happened. Reconcile the inventory before deciding +what to do; **never automatically retry it**. + +Requests with the same inspect/profile key or item-mutation key are serialized. +Rewards without a request identifier are serialized globally within their notification +class. Once a sent request fails or is cancelled, its key is quarantined for that +client's lifetime: subsequent queued/new work receives `UNCERTAIN_PREVIOUS_RESULT`. +A reconnect does not reset quarantine. After independently reconciling the result, +dispose the client and construct a new one to begin a new correlation lifetime. +Unsent failures do not quarantine a key. Different keys remain usable. + +Serialization prevents simultaneous ambiguous requests and a synchronous duplicate +response from completing queued work. The protocol cannot distinguish an arbitrarily +late duplicate of a successful operation from the response to a later operation on +the same key. Do not interpret a notification alone as proof of a mutation; verify +inventory state. Inventory messages lacking versions can be deduplicated by content, +but cannot be reliably reordered. Version tombstones last until a fresh welcome cache. +Fire-and-forget APIs have no request lifecycle and remain caller-coordinated. + +Decoded event types are generated from the checked-in schema in `types/responses.d.ts`. +64-bit integers are decimal strings, absent scalars/messages are null, and repeated +fields are arrays. `ItemInfo.accountid` correctly reflects the wire's uint32 number. +Absent paintwear now stays null instead of being coerced to zero. Malformed wire +payloads (including nested truncations) are rejected before protobuf decoding; attach +an `error` listener to observe incoming decoding failures as with other GC errors. + +## Opt-in authenticated live runner + +Nothing authenticates during `npm test`. To run the default read-only self-profile +case, provide credentials through your environment and explicitly enable the runner: + +```sh +CS2_LIVE=1 STEAM_REFRESH_TOKEN=... npm run test:live +``` + +Alternatively set `STEAM_ACCOUNT_NAME`, `STEAM_PASSWORD`, and, if needed, +`STEAM_TWO_FACTOR_CODE`. Do not commit credentials. No refresh token is written to +disk. Select a single named case with `-- --case NAME`; there is no “all mutations” +option. `CS2_LIVE_CONFIG` points to a local JSON file, for example: + +```json +{ + "testItemIds": ["111", "222"], + "cases": { + "inspect-test-item": { "method": "inspect", "args": ["M1A111D3"] }, + "apply-test-patch": { + "method": "applyPatch", "args": ["111", "222", 0], + "expectedInventory": [{ "id": "222", "present": false }] + } + } +} +``` + +Replace these illustrative IDs with sacrificial items in your own inventory. All +consumed/modified IDs must appear in `testItemIds`, exist before the operation, and +have an explicit postcondition which does not already hold. For a present-item +postcondition supply `properties` (exact values of normalized inventory properties). +Use a postcondition specific enough to demonstrate the intended result; patch tool +consumption alone may be weaker evidence than also asserting the target's attributes. +The runner requires both the method's correlated notification and that postcondition. +It invokes a mutation exactly once; failure to observe the postcondition is uncertain. + +Supported read methods: `profile`, `inspect`, `missions`, `casket`. Individually +selectable mutations: `applyPatch`, `removePatch`, `extractSticker`, +`encapsulateSticker`, `applyKeychain`, `openCrate`. Other mutations remain excluded +until a meaningful automated state assertion is available. There are no automatic +retries, including after Steam/GC timeout, disconnect, or ambiguous notification. + +The sanitized JSON report records UTC time, case name, method, protocol snapshot, +and outcome. Exit codes: 0 passed; 1 assertion failure; 2 unavailable account/config +prerequisite; 3 Steam/GC outage; 4 uncertain mutation. Missing acknowledgements can +also indicate a protocol mismatch: an “outage” result is not proof of its cause. +A successful send never counts as a pass. Retain reports alongside sanitized inventory +evidence before upgrading any matrix entry to live-verified; include account +prerequisites, verification date, exact snapshot, and the operation-specific assertion. diff --git a/README.md b/README.md index d558cec..8d0bba9 100644 --- a/README.md +++ b/README.md @@ -201,3 +201,15 @@ MIT License - see [LICENSE](LICENSE) file for details. Use Node.js 24+ for the current lint and test tooling. The published library retains Node.js 14+ runtime support. The development dependency uses `steam-user` 5; consumers may continue using the supported `steam-user` 4 API. To refresh protocols, run `npm run update-protos`. Each download uses one upstream commit; set `PROTO_REF=` to reproduce a specific snapshot. + +## Verification and request lifecycle + +See the [feature support matrix and migration notes](FEATURE_SUPPORT.md) for separate +payload, decoding, offline lifecycle, and live evidence. Version 2.6 adds +`cancelPendingRequests()`, `dispose()`, typed response events, and structured +`NodeCS2.RequestError` failures. Sent failures quarantine their correlation key; mutations +are never automatically retried. Existing callback and Promise signatures are retained. + +Run `npm test`, `npm run test:types`, and `npm run test:consumer` for offline verification. +Authenticated checks are explicitly opt-in through `npm run test:live`; see the matrix +for credential setup and individual mutation selection. No live behavior is claimed here. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 52e69d4..c9f6614 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,27 @@ # Release Notes +## v2.6.0 - Request lifecycle and verification + +- Added a reusable fake Steam transport, independent outgoing/incoming wire fixtures, + and a shared randomized inspect corpus. New tests are no longer hidden by the test-directory ignore rule. +- Centralized request cleanup and same-key serialization; added `cancelPendingRequests()`, + `dispose()`, and `RequestError` codes with mutation uncertainty. Failed sent keys remain + quarantined for the client lifetime and are never automatically retried. +- Fixed subscription-after-send races, wrong notification correlation, discarded additional + profiles, absent wear coerced to zero, duplicate/reordered inventory changes, unsigned + storage-unit IDs, and malformed nested protobuf/craft payload handling. +- Added schema-generated public response/event types and compile-time consumer examples. + Corrected Promise overload inference and the numeric `ItemInfo.accountid` declaration. +- Added an explicit [feature support matrix](FEATURE_SUPPORT.md) and a safe, opt-in + authenticated live runner with individually designated mutations and inventory assertions. +- Added packed consumer CI across Node 14/16/18/20/22/24 and steam-user 4.2.0/4.29.3/5.3.0. + +Migration: existing calls remain compatible. Handle request rejections, reconcile uncertain +mutations before any manual retry, and dispose unused clients. Read FEATURE_SUPPORT.md +for quarantine semantics and callback differences. Live Steam/GC behavior remains unverified; +schema-correct sends and offline notifications do not establish server-side feature support. + + ## v2.5.0 - September CS2 Protocol Refresh ### Added diff --git a/bun.lock b/bun.lock index 3cdc86e..ead4be9 100644 --- a/bun.lock +++ b/bun.lock @@ -12,11 +12,14 @@ "steamid": "^2.1.0", }, "devDependencies": { + "@types/node": "^24.0.0", + "@types/steam-user": "^5.1.1", "eslint": "^10.10.0", "eslint-config-prettier": "^10.1.8", "prettier": "^3.9.6", "protobufjs-cli": "^1.3.3", "steam-user": "^5.3.0", + "typescript": "^5.9.3", "vitest": "^4.1.11", }, "peerDependencies": { @@ -135,6 +138,8 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@types/bytebuffer": ["@types/bytebuffer@5.0.49", "", { "dependencies": { "@types/long": "^3.0.0", "@types/node": "*" } }, "sha512-lV4YLiolMdD4upDmr4vnfiwV/FN9Jg33eNTSFMkHqyMKqTIAn5TmFTYsARwXwUXeFU7yzRpECmWV1SOhIvdkRQ=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], @@ -143,17 +148,23 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + "@types/file-manager": ["@types/file-manager@2.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-gEq6de+iMbjmkL3Wj183JQ651h9b331wQ3svXp2K6RkgBg+TtiyIZTMols/a3UtXllVV9kSelz/N3Rgh0n6S4g=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], - "@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], + "@types/long": ["@types/long@3.0.32", "", {}, "sha512-ZXyOOm83p7X8p3s0IYM3VeueNmHpkk/yMlP8CLeOnEcu6hIwPH7YjZBvhQkR0ZFS2DqZAxKtJ/M5fcuv3OU5BA=="], "@types/markdown-it": ["@types/markdown-it@14.2.0", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ=="], "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], - "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "@types/node": ["@types/node@24.19.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ=="], + + "@types/steam-user": ["@types/steam-user@5.1.1", "", { "dependencies": { "@types/bytebuffer": "*", "@types/file-manager": "*", "@types/node": "*", "@types/steamid": "*" } }, "sha512-jYAsDpp30eC+/EWwG9Ea2qyGuKBWsTXgl7qVUiAMP3YtsCJi4/rCj0w8W8167RdhrkY7FhSC0OScQt5fYK8cGQ=="], + + "@types/steamid": ["@types/steamid@2.0.4", "", {}, "sha512-LC0oaiNq3gqMI18MV935CnlyHmDooKRszK56jHv0+b7EbJwf5k++YsD52zWeJUD8nuYqP9LpIgpXGiYBOxpcYQ=="], "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], @@ -475,13 +486,15 @@ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], - "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], @@ -509,6 +522,12 @@ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "@types/bytebuffer/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + + "@types/file-manager/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + + "@types/steam-user/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "binarykvparser/long": ["long@3.2.0", "", { "bundled": true }, "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg=="], "bytebuffer/long": ["long@3.2.0", "", {}, "sha512-ZYvPPOMqUwPoDsbJaR10iQJYnMuZhRTvHYl62ErLIEX7RgFlziSBUUvrt3OVfc47QlHHpzPZYP17g3Fv7oeJkg=="], @@ -523,6 +542,8 @@ "jsdoc/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "protobufjs/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "protobufjs-cli/espree": ["espree@9.6.1", "", { "dependencies": { "acorn": "^8.9.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^3.4.1" } }, "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ=="], "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], @@ -533,6 +554,12 @@ "steam-appticket/steamid": ["steamid@1.1.3", "", { "dependencies": { "cuint": "^0.2.1" } }, "sha512-t86YjtP1LtPt8D+TaIARm6PtC9tBnF1FhxQeLFs6ohG7vDUfQuy/M8II14rx1TTUkVuYoWHP/7DlvTtoCGULcw=="], + "@types/bytebuffer/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@types/file-manager/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "@types/steam-user/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + "escodegen/optionator/levn": ["levn@0.3.0", "", { "dependencies": { "prelude-ls": "~1.1.2", "type-check": "~0.3.2" } }, "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA=="], "escodegen/optionator/prelude-ls": ["prelude-ls@1.1.2", "", {}, "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w=="], @@ -543,8 +570,16 @@ "protobufjs-cli/espree/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + "protobufjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "steam-appticket/protobufjs/@types/long": ["@types/long@4.0.2", "", {}, "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA=="], + + "steam-appticket/protobufjs/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "steam-appticket/protobufjs/long": ["long@4.0.0", "", {}, "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "steam-appticket/protobufjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], } } diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000..52a59fa --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,50 @@ +# Shared inspect wire corpus + +`inspect-corpus.json` is committed identically in `sak0a/node-cs2` and +`sak0a/cs2-inspect-lib`. Run `python3 scripts/generate-inspect-corpus.py` and copy +the resulting file to the other repository when extending it. The generator +uses only Python's standard library: explicit protobuf tags/varints, `struct` +IEEE754 conversion, and `zlib.crc32`. Neither project encoder contributes input +bytes or expected values. The seed is fixed at 73020261002. + +These are **synthetic independent fixtures, not sanitized Steam captures**. +No authenticated replay was available for this change; passing this corpus is +not evidence that a live GC accepts a mutation or that account prerequisites +are met. The schema is `CEconItemPreviewDataBlock` and its nested `Sticker` in +the repositories' recorded GameTracking-CS2 protocol snapshot. + +The 56 valid cases cover uint64 zero, safe-integer boundaries and maximum; +float32 zero, subnormal, rounded fractions and one; uint32 maximum; negative +int32; repeated Unicode names including empty strings and combining marks; +stickers, keychains, variations, transformations, opaque bytes; and unknown +varint, fixed32, fixed64 and length-delimited fields, including nested unknown +fields. Forty-eight cases are reproducible randomized combinations. Five +structurally malformed payloads have correct independent checksums, ensuring +framing validity cannot hide truncated fields. Tests additionally corrupt the +checksum of every valid case. + +## Semantic agreement and documented representation differences + +- `node-cs2` exposes uint64 item IDs as decimal strings; `cs2-inspect-lib` + exposes them as bigint. Compare their exact decimal values, never Numbers. +- `node-cs2` exposes binary data as Buffer; the browser core uses Uint8Array. + Compare the byte sequence, including explicit empty bytes. +- Missing optional node-cs2 scalars generally appear as null. The inspect core + omits optional values and supplies its historical required model defaults + (defindex, paintindex, paintseed and paintwear = 0). The corpus compares all + explicitly encoded supported fields; defaults are not evidence of wire + presence. +- An absent repeated name field is `[]` in node-cs2 and may be undefined in + the inspect model. Both retain every encoded occurrence, including empty + names; the compatibility `customname` alias is the last occurrence. +- Floats are compared after IEEE754 float32 encoding. Unknown wire fields + are not part of either decoded item model. `InspectDocument` additionally + retains original unknown field segments and opaque bytes when editing; + nested attachment edits preserve that attachment's unknown segments. +- The historical inspect decoder accepts legacy unchecked zero CRC framing. + Strict `decodeInspectDocument` and node-cs2 embedded decoding verify CRC. + +The packed inspect consumer validation installs both libraries and compares +the actual outputs field by field. Set `NODE_CS2_PACKAGE` to a prerequisite +npm tarball to validate unreleased dependency combinations, and rerun without +that override after publication before claiming registry compatibility. diff --git a/fixtures/inspect-corpus.json b/fixtures/inspect-corpus.json new file mode 100644 index 0000000..5c5e04a --- /dev/null +++ b/fixtures/inspect-corpus.json @@ -0,0 +1,1813 @@ +{ + "formatVersion": 1, + "provenance": { + "kind": "independently-encoded-synthetic", + "generator": "scripts/generate-inspect-corpus.py", + "seed": 73020261002, + "encoding": "Python stdlib varints, struct IEEE754 and zlib CRC32; no project codec or generated protobuf used", + "schema": "CEconItemPreviewDataBlock and nested Sticker in Valve GameTracking-CS2 cstrike15_gcmessages.proto", + "liveCapture": false + }, + "unknownSegments": { + "top": "a806ffffffffffffffffff01b2060400ff80febd0678563412c1068877665544332211", + "nested": "ea040300ffaaf0048180808080808010" + }, + "valid": [ + { + "name": "uint64-0", + "protobufHex": "10001807202c38004000", + "token": "0010001807202C38004000C6B2A054", + "expected": { + "itemid": "0", + "defindex": 7, + "paintindex": 44, + "paintseed": 0, + "paintwear": 0.0 + } + }, + { + "name": "uint64-1", + "protobufHex": "10011807202c38808080fc034001", + "token": "A5B5A4BDA285899D25252559A6E5A4E623E52C", + "expected": { + "itemid": "1", + "defindex": 7, + "paintindex": 44, + "paintseed": 1, + "paintwear": 1.0 + } + }, + { + "name": "uint64-9007199254740991", + "protobufHex": "10ffffffffffffff0f1807202c38cd99b3ee034002", + "token": "FFEF00000000000000F0E7F8DFD3C732664C11FCBFFD645B192F", + "expected": { + "itemid": "9007199254740991", + "defindex": 7, + "paintindex": 44, + "paintseed": 2, + "paintwear": 0.10000000149011612 + } + }, + { + "name": "uint64-9007199254740992", + "protobufHex": "1080808080808080101807202c389ab3e6f0034003", + "token": "001080808080808080101807202C389AB3E6F003400369F1E974", + "expected": { + "itemid": "9007199254740992", + "defindex": 7, + "paintindex": 44, + "paintseed": 3, + "paintwear": 0.15000000596046448 + } + }, + { + "name": "uint64-9007199254740993", + "protobufHex": "1081808080808080101807202c38014004", + "token": "A5B524252525252525B5BDA285899DA4E5A1A143E5E5", + "expected": { + "itemid": "9007199254740993", + "defindex": 7, + "paintindex": 44, + "paintseed": 4, + "paintwear": 1.401298464324817e-45 + } + }, + { + "name": "uint64-18446744073709551615", + "protobufHex": "10ffffffffffffffffff011807202c38fffffffb034005", + "token": "FFEF000000000000000000FEE7F8DFD3C700000004FCBFFA8585F669", + "expected": { + "itemid": "18446744073709551615", + "defindex": 7, + "paintindex": 44, + "paintseed": 5, + "paintwear": 0.9999999403953552 + } + }, + { + "name": "all-fields-unknown", + "protobufHex": "08ffffffff0f10ffffffffffffffffff011807202c28063000389ab3e6f00340ffffffff0f480050ffffffff0f680070007800800100880100900180808080f8ffffffff01980100a80100b80100c001ffffffff0f5a005a0be9be8df09f94a52065cc815a02c3a95a0be9be8df09f94a52065cc81623e080010011dcdcccc3d250000803f2d000034c330003dcdcc4c3e459a9999be4d0000000050ffffffff0f58006001ea040300ffaaf0048180808080808010a2011b080010143d0000003e507bea040300ffaaf0048180808080808010b20116080010645032ea040300ffaaf0048180808080808010ca010700ff80fe000102a806ffffffffffffffffff01b2060400ff80febd0678563412c1068877665544332211", + "token": "0008FFFFFFFF0F10FFFFFFFFFFFFFFFFFF011807202C28063000389AB3E6F00340FFFFFFFF0F480050FFFFFFFF0F680070007800800100880100900180808080F8FFFFFFFF01980100A80100B80100C001FFFFFFFF0F5A005A0BE9BE8DF09F94A52065CC815A02C3A95A0BE9BE8DF09F94A52065CC81623E080010011DCDCCCC3D250000803F2D000034C330003DCDCC4C3E459A9999BE4D0000000050FFFFFFFF0F58006001EA040300FFAAF0048180808080808010A2011B080010143D0000003E507BEA040300FFAAF0048180808080808010B20116080010645032EA040300FFAAF0048180808080808010CA010700FF80FE000102A806FFFFFFFFFFFFFFFFFF01B2060400FF80FEBD0678563412C1068877665544332211F0E8CE48", + "expected": { + "accountid": 4294967295, + "itemid": "18446744073709551615", + "defindex": 7, + "paintindex": 44, + "rarity": 6, + "quality": 0, + "paintwear": 0.15000000596046448, + "paintseed": 4294967295, + "killeaterscoretype": 0, + "killeatervalue": 4294967295, + "customnames": [ + "", + "龍🔥 é", + "é", + "龍🔥 é" + ], + "inventory": 0, + "origin": 0, + "questid": 0, + "dropreason": 0, + "musicindex": 0, + "entindex": -2147483648, + "petindex": 0, + "style": 0, + "upgrade_level": 0, + "pet_food_expiration_date": 4294967295, + "blobdata": "00ff80fe000102", + "stickers": [ + { + "slot": 0, + "sticker_id": 1, + "wear": 0.10000000149011612, + "scale": 1, + "rotation": -180, + "tint_id": 0, + "offset_x": 0.20000000298023224, + "offset_y": -0.30000001192092896, + "offset_z": 0, + "pattern": 4294967295, + "highlight_reel": 0, + "wrapped_sticker": 1 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 20, + "pattern": 123, + "offset_x": 0.125 + } + ], + "variations": [ + { + "slot": 0, + "sticker_id": 100, + "pattern": 50 + } + ] + } + }, + { + "name": "empty-and-explicit-zero", + "protobufHex": "100018002000380040005a006209080010001d00000000ca0100", + "token": "A5B5A5BDA585A59DA5E5A5FFA5C7ACADA5B5A5B8A5A5A5A56FA4A5CCF89F9A", + "expected": { + "itemid": "0", + "defindex": 0, + "paintindex": 0, + "paintseed": 0, + "paintwear": 0, + "customnames": [ + "" + ], + "blobdata": "", + "stickers": [ + { + "slot": 0, + "sticker_id": 0, + "wear": 0 + } + ] + } + }, + { + "name": "seeded-00", + "protobufHex": "10f4deb1d7e28e968cb001180720f50a38fad4aff90340f2ae86a8045a04746573746212080010cfd9cfc4071d0db01c3e2d742d9ac2a2010e080010b08ee2ea0c50edfcbfcb04ca0108abaeeeebd26d31e5", + "token": "FFEF0B214E281D7169734FFEE7F8DF0AF5C7052B5006FCBF0D517957FBA5FB8B9A8C8B9DEDF7FFEF3026303BF8E2F24FE3C1D28BD2653D5DFEF1F7FFEF4F711D15F3AF12034034FB35FEF7545111142D92CE1A15131470", + "expected": { + "itemid": "12688989214665895796", + "defindex": 7, + "paintindex": 1397, + "paintseed": 1157732210, + "paintwear": 0.6715465784072876, + "customnames": [ + "test" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2022960335, + "wear": 0.15301533043384552, + "rotation": -77.08877563476562 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3445131056, + "pattern": 1232076397 + } + ], + "blobdata": "abaeeeebd26d31e5" + } + }, + { + "name": "seeded-01", + "protobufHex": "10f385c9d2968ce9f52e1801209e0d38da84b6f90340e1e4bb950d5a09e697a5e69cace8aa9e5a00a2010e080010ebc09ddb0750bbbf95850eca0101a3", + "token": "0010F385C9D2968CE9F52E1801209E0D38DA84B6F90340E1E4BB950D5A09E697A5E69CACE8AA9E5A00A2010E080010EBC09DDB0750BBBF95850ECA0101A30CA04D6C", + "expected": { + "itemid": "3380976683574248179", + "defindex": 1, + "paintindex": 1694, + "paintseed": 3534680673, + "paintwear": 0.6777702569961548, + "customnames": [ + "日本語", + "" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2070372459, + "pattern": 3768934331 + } + ], + "blobdata": "a3" + } + }, + { + "name": "seeded-02", + "protobufHex": "10a7e0af93dcd289e272181020bf043889aea9f40340c8d3fe990c5a09e697a5e69cace8aa9e6212080010b4c8e4c8071d6ca3b33e2d548eb5426212080110eca58ef4031de431ea3e2d00a25242a2010e080010f595a0bb0d50e9e2cecd02ca010f71978bbcaf35585c60d51e6955041f", + "token": "A5B502450A3679772C47D7BDB5851AA19D2C0B0C51A6E56D765B3CA9FFAC4332004339094D0F3BC7B7ADA5B5116D416DA2B8C906169B88F12B10E7C7B7ADA4B549002B51A6B841944F9B88A507F7E707A4ABADA5B55030051EA8F54C476B68A76FA4AAD4322E190A90FDF9C570BBCCF0A1BAAAC5EAE5", + "expected": { + "itemid": "8269777240412909607", + "defindex": 16, + "paintindex": 575, + "paintseed": 3275729352, + "paintwear": 0.27019527554512024, + "customnames": [ + "日本語" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2031690804, + "wear": 0.3508561849594116, + "rotation": 90.77798461914062 + }, + { + "slot": 1, + "sticker_id": 1048810220, + "wear": 0.45741188526153564, + "rotation": 52.658203125 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3613919989, + "pattern": 699642217 + } + ], + "blobdata": "71978bbcaf35585c60d51e6955041f" + } + }, + { + "name": "seeded-03", + "protobufHex": "109cd3fffc8deddaadac01180120ef0b38c1a5d0f80340cec2e3f2025a09e697a5e69cace8aa9e5a0062120800109ea8f5d6091d138dab3d2df5a303436212080110a7abe1a4011dd533463e2db9d72fc36212080210feadb3ae011d556d0f3f2d256e8bc2a2010e080010df91f4a0055083efd8b00eca01033cd049", + "token": "FFEF632C00037212255253FEE7FEDF10F4C73E5A2F07FCBF313D1C0DFDA5F619685A196353175561A5FF9DEDF7FFEF61570A29F6E2EC7254C2D20A5CFCBC9DEDF7FEEF58541E5BFEE22ACCB9C1D24628D03C9DEDF7FDEF01524C51FEE2AA92F0C0D2DA91743D5DFEF1F7FFEF206E0B5FFAAF7C10274FF135FEFCC32FB6DFCCF81E", + "expected": { + "itemid": "12419638495576844700", + "defindex": 1, + "paintindex": 1519, + "paintseed": 777576782, + "paintwear": 0.5784111618995667, + "customnames": [ + "日本語", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2598196254, + "wear": 0.0837651714682579, + "rotation": 131.6404571533203 + }, + { + "slot": 1, + "sticker_id": 345527719, + "wear": 0.19355709850788116, + "rotation": -175.84266662597656 + }, + { + "slot": 2, + "sticker_id": 365745918, + "wear": 0.5602620244026184, + "rotation": -69.71512603759766 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1411188959, + "pattern": 3860215683 + } + ], + "blobdata": "3cd049" + } + }, + { + "name": "seeded-04", + "protobufHex": "10ef9aa58fd68eeffcfb01180120d30138a0e7a8f80340bff49eb8015a04f09f94a56212080010e8ba878b0b1d792d903e2d738d19436212080110a6b7a5a0051dfcef203e2d16869b426212080210ace9bfd10e1d0738d73d2dd735bb42a2010e080010c780dfe70f50c594aaae0eca0105dfb4ca09b8", + "token": "0010EF9AA58FD68EEFFCFB01180120D30138A0E7A8F80340BFF49EB8015A04F09F94A56212080010E8BA878B0B1D792D903E2D738D19436212080110A6B7A5A0051DFCEF203E2D16869B426212080210ACE9BFD10E1D0738D73D2DD735BB42A2010E080010C780DFE70F50C594AAAE0ECA0105DFB4CA09B8A5DAB14C", + "expected": { + "itemid": "18156750585060740463", + "defindex": 1, + "paintindex": 211, + "paintseed": 386382399, + "paintwear": 0.5398502349853516, + "customnames": [ + "🔥" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2975980904, + "wear": 0.28159692883491516, + "rotation": 153.5525360107422 + }, + { + "slot": 1, + "sticker_id": 1409899430, + "wear": 0.15716546773910522, + "rotation": 77.76188659667969 + }, + { + "slot": 2, + "sticker_id": 3929011372, + "wear": 0.10508733242750168, + "rotation": 93.60515594482422 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 4244095047, + "pattern": 3855256133 + } + ], + "blobdata": "dfb4ca09b8" + } + }, + { + "name": "seeded-05", + "protobufHex": "10e8d5b58fadbed3a3ba011801206338b1d9d8fa0340a8decb87086212080010e6c1fcd80e1d136cdc3e2de9981a43a2010e080010b596a7a20c50dee3a1a00aca0100", + "token": "A5B54D70102A081B76061FA4BDA485C69D147C7D5FA6E50D7B6E22ADC7B7ADA5B54364597DABB8B6C9799B884C3DBFE607A4ABADA5B510330207A9F57B460405AF6FA4A5BB528BF7", + "expected": { + "itemid": "13422782919700474600", + "defindex": 1, + "paintindex": 99, + "paintseed": 2163404584, + "paintwear": 0.8366194367408752, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 3944685798, + "wear": 0.43051204085350037, + "rotation": 154.59730529785156 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3293170485, + "pattern": 2752016862 + } + ], + "blobdata": "" + } + }, + { + "name": "seeded-06", + "protobufHex": "10f7e8849ad9ccbfb88001180720a20a388ab9adf90340af9df3d8035a005a0474657374621208001089b29bbc021db322703e2d4c1101406212080110cb80af87061dad9dd83e2d134732c2a2010e080010cdd1e8b9075085c39ff004ca01082eb14c6afe990074", + "token": "FFEF08177B65263340477FFEE7F8DF5DF5C775465206FCBF50620C27FCA5FFA5FB8B9A8C8B9DEDF7FFEF764D6443FDE24CDD8FC1D2B3EEFEBF9DEDF7FEEF347F5078F9E2526227C1D2ECB8CD3D5DFEF1F7FFEF322E1746F8AF7A3C600FFB35FEF7D14EB3950166FF8B01F3C948", + "expected": { + "itemid": "9255176946462045303", + "defindex": 7, + "paintindex": 1314, + "paintseed": 991743663, + "paintwear": 0.669380784034729, + "customnames": [ + "", + "test" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 663148809, + "wear": 0.23450736701488495, + "rotation": 2.0166807174682617 + }, + { + "slot": 1, + "sticker_id": 1626062923, + "wear": 0.4230779707431793, + "rotation": -44.56940841674805 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2000300237, + "pattern": 1309139333 + } + ], + "blobdata": "2eb14c6afe990074" + } + }, + { + "name": "seeded-07", + "protobufHex": "10fddbc7fcd0d5f8ebe301180120f50e388cef98f20340e1d4bc8a095a005a0361cc816212080010bfe3a590051da5bb5c3f2d2c1d1bc2621108011095c7930a1d76267f3f2df4521343a2010e080010a3fe9ee70550f7fefe8b03ca010d9549ae378e4247e35e8c7610cc", + "token": "0010FDDBC7FCD0D5F8EBE301180120F50E388CEF98F20340E1D4BC8A095A005A0361CC816212080010BFE3A590051DA5BB5C3F2D2C1D1BC2621108011095C7930A1D76267F3F2DF4521343A2010E080010A3FE9EE70550F7FEFE8B03CA010D9549AE378E4247E35E8C7610CCD7893284", + "expected": { + "itemid": "16417840199520873981", + "defindex": 1, + "paintindex": 1909, + "paintseed": 2437884513, + "paintwear": 0.1935712695121765, + "customnames": [ + "", + "á" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 1376350655, + "wear": 0.8622382283210754, + "rotation": -38.77848815917969 + }, + { + "slot": 1, + "sticker_id": 21291925, + "wear": 0.9966806173324585, + "rotation": 147.32403564453125 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1558691619, + "pattern": 830455671 + } + ], + "blobdata": "9549ae378e4247e35e8c7610cc" + } + }, + { + "name": "seeded-08", + "protobufHex": "10e08ac392b1bc9ecc70180120880138f6f4f8ef0340abecb38b0e5a04f09f94a562110800108a91be0a1d06c90c3f2d4fff2243a2010e080010ebb59f8b0d509bea80930dca010ba21e1306e0790a0fe21d5b", + "token": "A5B5452F663714193B69D5BDA4852DA49D53515D4AA6E50E49162EABFFA1553A3100C7B4ADA5B52F341BAFB8A36CA99A88EA5A87E607A4ABADA5B54E103A2EA8F53E4F2536A86FA4AE07BBB6A345DCAFAA47B8FEB5906F4D", + "expected": { + "itemid": "8113368744879768928", + "defindex": 1, + "paintindex": 136, + "paintseed": 3782014507, + "paintwear": 0.12413494288921356, + "customnames": [ + "🔥" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 21989514, + "wear": 0.5499423742294312, + "rotation": 162.99729919433594 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3513244395, + "pattern": 3529520411 + } + ], + "blobdata": "a21e1306e0790a0fe21d5b" + } + }, + { + "name": "seeded-09", + "protobufHex": "10b3d6b09ce4a9eedaa801183c208b0238abc4d9f40340cfcebecd086212080010d8a69ac90f1d8569563f2dbf7dd6426212080110f5ffe5ea0d1db9a69f3e2d9820d6c26212080210b9fd93d10a1d1c33233d2d2dc4fe42a2010e0800109094f5f40550dff1cdac07ca010b335938a009bcf34f24de7f", + "token": "FFEF4C294F631B56112557FEE7C3DF74FDC7543B260BFCBF30314132F79DEDF7FFEF27596536F0E27A96A9C0D2408229BD9DEDF7FEEF0A001A15F2E2465960C1D267DF293D9DEDF7FDEF46026C2EF5E2E3CCDCC2D2D23B01BD5DFEF1F7FFEF6F6B0A0BFAAF200E3253F835FEF4CCA6C75FF6430CB0DB2180FFA186A9", + "expected": { + "itemid": "12156826514948369203", + "defindex": 60, + "paintindex": 267, + "paintseed": 2309990223, + "paintwear": 0.29371771216392517, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 4180054872, + "wear": 0.8375476002693176, + "rotation": 107.24559783935547 + }, + { + "slot": 1, + "sticker_id": 3713630197, + "wear": 0.3118188679218292, + "rotation": -107.06365966796875 + }, + { + "slot": 2, + "sticker_id": 2854551225, + "wear": 0.039843663573265076, + "rotation": 127.3831558227539 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1587366416, + "pattern": 1972599007 + } + ], + "blobdata": "335938a009bcf34f24de7f" + } + }, + { + "name": "seeded-10", + "protobufHex": "10cfd8fbbdc6f5e0d56118072001388c96fefb034087f4ffc4025a04f09f94a55a04746573746211080010a4a3b5601da5d1683f2d53861dc262120801109abc89c90d1d8222b03d2de58ccec26212080210dcf790cd0d1d49b85f3f2d69fe2642a2010e0800109eaeb18e07508bb786b705ca010760e391fffc7e28", + "token": "0010CFD8FBBDC6F5E0D56118072001388C96FEFB034087F4FFC4025A04F09F94A55A04746573746211080010A4A3B5601DA5D1683F2D53861DC262120801109ABC89C90D1D8222B03D2DE58CCEC26212080210DCF790CD0D1D49B85F3F2D69FE2642A2010E0800109EAEB18E07508BB786B705CA010760E391FFFC7E2833279AB1", + "expected": { + "itemid": "7037863619194711119", + "defindex": 7, + "paintindex": 1, + "paintseed": 681572871, + "paintwear": 0.9982154369354248, + "customnames": [ + "🔥", + "test" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 202199460, + "wear": 0.9094489216804504, + "rotation": -39.38117599487305 + }, + { + "slot": 1, + "sticker_id": 3642908186, + "wear": 0.08600331842899323, + "rotation": -103.27518463134766 + }, + { + "slot": 2, + "sticker_id": 3651419100, + "wear": 0.8739057183265686, + "rotation": 41.74844741821289 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1909217054, + "pattern": 1457625995 + } + ], + "blobdata": "60e391fffc7e28" + } + }, + { + "name": "seeded-11", + "protobufHex": "10d0cb8ab1ca9ea2870d180720e90738dbaacaf90340ecf8dda0066212080010d3c5c888091dabdc3d3e2d91942a436212080110e59389810c1dbf69923e2dfcd6da42a2010e080010d9b4bfa90b50f1d9dcd20aca010b85e422d093fe3aad561041", + "token": "A5B5756E2F146F3B0722A8BDA2854CA29D7E0F6F5CA6E5495D7805A3C7B7ADA5B576606D2DACB80E79989B8834318FE6C7B7ADA4B540362C24A9B81ACC379B8859737FE707A4ABADA5B57C111A0CAEF5547C7977AF6FA4AE20418775365B9F08F3B5E4C364EFD0", + "expected": { + "itemid": "940839956507698640", + "defindex": 7, + "paintindex": 1001, + "paintseed": 1679260780, + "paintwear": 0.6975914835929871, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 2433884883, + "wear": 0.18541209399700165, + "rotation": 170.58033752441406 + }, + { + "slot": 1, + "sticker_id": 3223472613, + "wear": 0.2859630286693573, + "rotation": 109.41989135742188 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3039812185, + "pattern": 2857839857 + } + ], + "blobdata": "85e422d093fe3aad561041" + } + }, + { + "name": "seeded-12", + "protobufHex": "10b8dbca9d9582f397a001180720ac0438c4a588fb0340fff4fb830b5a09e697a5e69cace8aa9e5a09e697a5e69cace8aa9e5a09e697a5e69cace8aa9ea2010e080010f3c6b1e10b50b3edd9f805ca010f947eaee2ae6cb4030f56ab565fada3", + "token": "FFEF472435626A7D0C685FFEE7F8DF53FBC73B5A7704FCBF000B047CF4A5F619685A196353175561A5F619685A196353175561A5F619685A1963531755615DFEF1F7FFEF0C394E1EF4AF4C122607FA35FEF06B81511D51934BFCF0A954A9A0525C5CE93ABF", + "expected": { + "itemid": "11542668744764599736", + "defindex": 7, + "paintindex": 556, + "paintseed": 2961111679, + "paintwear": 0.883098840713501, + "customnames": [ + "日本語", + "日本語", + "日本語" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 3157025651, + "pattern": 1595307699 + } + ], + "blobdata": "947eaee2ae6cb4030f56ab565fada3" + } + }, + { + "name": "seeded-13", + "protobufHex": "10ac9bdd90968bb4894e180920bc0d38b8dfd3e60340e0f8e6a6086212080010a8d1a2b5071d61dd563f2d36282fc3a2010e080010bfafd5820d50ef84ed8a06ca01032e8078", + "token": "0010AC9BDD90968BB4894E180920BC0D38B8DFD3E60340E0F8E6A6086212080010A8D1A2B5071D61DD563F2D36282FC3A2010E080010BFAFD5820D50EF84ED8A06CA01032E8078AAD1AF7C", + "expected": { + "itemid": "5625787966855531948", + "defindex": 9, + "paintindex": 1724, + "paintseed": 2228862048, + "paintwear": 0.025993213057518005, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 1990764712, + "wear": 0.8393154740333557, + "rotation": -175.15707397460938 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3495253951, + "pattern": 1633370735 + } + ], + "blobdata": "2e8078" + } + }, + { + "name": "seeded-14", + "protobufHex": "108dc8bedad1d9a8a1dd01180720ed0e38dce086f00340c8f2beb705a2010e08001097adedc70950f2f7e6e203ca01035ac6d3", + "token": "A5B5286D1B7F747C0D0478A4BDA28548AB9D79452355A6E56D571B12A007A4ABADA5B532084862ACF557524347A66FA4A6FF6376546495BF", + "expected": { + "itemid": "15943484632655176717", + "defindex": 7, + "paintindex": 1901, + "paintseed": 1458551112, + "paintwear": 0.12664932012557983, + "customnames": [], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2566608535, + "pattern": 1012513778 + } + ], + "blobdata": "5ac6d3" + } + }, + { + "name": "seeded-15", + "protobufHex": "10aeabd4b1ceaf9495e101181020980238f0daa3fa0340b0feb2165a0361cc815a0361cc816212080010bdccb9c70b1d95b3413f2dd82d1ac36212080110bb95d7fd091dd8e7073f2df376ee41621208021097e8ba8f0c1de4a6033f2d8f7431c2a2010e080010df83efa40a50f8fc8ff80bca0109721ee674971a5432e0", + "token": "FFEF51542B4E31506B6A1EFEE7EFDF67FDC70F255C05FCBF4F014DE9A5FC9E337EA5FC9E337E9DEDF7FFEF42334638F4E26A4CBEC0D227D2E53C9DEDF7FEEF446A2802F6E22718F8C0D20C8911BE9DEDF7FDEF68174570F3E21B59FCC0D2708BCE3D5DFEF1F7FFEF207C105BF5AF07037007F435FEF68DE1198B68E5ABCD1F8207CEA7", + "expected": { + "itemid": "16224870204435666350", + "defindex": 16, + "paintindex": 280, + "paintseed": 46972720, + "paintwear": 0.7848730087280273, + "customnames": [ + "á", + "á" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 3102631485, + "wear": 0.756646454334259, + "rotation": -154.1790771484375 + }, + { + "slot": 1, + "sticker_id": 2679491259, + "wear": 0.530881404876709, + "rotation": 29.808080673217773 + }, + { + "slot": 2, + "sticker_id": 3253646359, + "wear": 0.5142652988433838, + "rotation": -44.363826751708984 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2761671135, + "pattern": 3204710008 + } + ], + "blobdata": "721ee674971a5432e0" + } + }, + { + "name": "seeded-16", + "protobufHex": "108ef391f98ce7afc879180720810438fbede1fa034084b6f48d076212080010bbfcfde6071d8086603f2d3ea9084362120801108bb0bceb0c1da5ee8d3e2d8fce1a43a2010e08001086c5c2970450dab6c9a10aca01080a23128fe9f78125", + "token": "00108EF391F98CE7AFC879180720810438FBEDE1FA034084B6F48D076212080010BBFCFDE6071D8086603F2D3EA9084362120801108BB0BCEB0C1DA5EE8D3E2D8FCE1A43A2010E08001086C5C2970450DAB6C9A10ACA01080A23128FE9F781250FEB723C", + "expected": { + "itemid": "8759711525949962638", + "defindex": 7, + "paintindex": 513, + "paintseed": 1908218628, + "paintwear": 0.8455654978752136, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 2095021627, + "wear": 0.8770523071289062, + "rotation": 136.66110229492188 + }, + { + "slot": 1, + "sticker_id": 3446609931, + "wear": 0.27721133828163147, + "rotation": 154.80686950683594 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1123066502, + "pattern": 2754763610 + } + ], + "blobdata": "0a23128fe9f78125" + } + }, + { + "name": "seeded-17", + "protobufHex": "10d1d5adeee890f5bc77180720b80a38d7e9bef80340cde9bb85075a005a006212080010e6c6fad4081dec61213d2d6af56242a2010d080010c591e0ea0a508fbdb83dca0103c8e5f4", + "token": "A5B57470084B4D355019D2BDA2851DAF9D724C1B5DA6E5684C1E20A2FFA5FFA5C7B7ADA5B543635F71ADB849C4849888CF50C7E707A4A8ADA5B56034454FAFF52A181D986FA4A66D4051804A6225", + "expected": { + "itemid": "8609145837065038545", + "defindex": 7, + "paintindex": 1336, + "paintseed": 1890514125, + "paintwear": 0.5613531470298767, + "customnames": [ + "", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2325652326, + "wear": 0.03940002620220184, + "rotation": 56.739662170410156 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2908227781, + "pattern": 128851599 + } + ], + "blobdata": "c8e5f4" + } + }, + { + "name": "seeded-18", + "protobufHex": "10e1e7e6aaf48297c626183c20910a38969592f803409af2a6b8015a09e697a5e69cace8aa9e5a09e697a5e69cace8aa9ea2010e080010fee3bc990150d2ded8ae04ca010d00a41027b34a6ae0b4c0e03992", + "token": "FFEF1E1819550B7D6839D9E7C3DF6EF5C7696A6D07FCBF650D5947FEA5F619685A196353175561A5F619685A1963531755615DFEF1F7FFEF011C4366FEAF2D212751FB35FEF2FF5BEFD84CB5951F4B3F1FC66DF0CAC8D1", + "expected": { + "itemid": "2777696325198263265", + "defindex": 60, + "paintindex": 1297, + "paintseed": 386513178, + "paintwear": 0.5177396535873413, + "customnames": [ + "日本語", + "日本語" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 321860094, + "pattern": 1171664722 + } + ], + "blobdata": "00a41027b34a6ae0b4c0e03992" + } + }, + { + "name": "seeded-19", + "protobufHex": "10dfdb96e297c789aeb201181020f40638a58bf7f80340cffcddcb0c6211080010b6b58f521d3af8f33e2db2c029c3a2010d080010f899c5f40850c8d69962ca0107cd57ce1d082cec", + "token": "0010DFDB96E297C789AEB201181020F40638A58BF7F80340CFFCDDCB0C6211080010B6B58F521D3AF8F33E2DB2C029C3A2010D080010F899C5F40850C8D69962CA0107CD57CE1D082CECA6D85748", + "expected": { + "itemid": "12852189464948485599", + "defindex": 16, + "paintindex": 884, + "paintseed": 3380051535, + "paintwear": 0.6162970662117004, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 172219062, + "wear": 0.4765031933784485, + "rotation": -169.75271606445312 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2391887096, + "pattern": 205941576 + } + ], + "blobdata": "cd57ce1d082cec" + } + }, + { + "name": "seeded-20", + "protobufHex": "10faf78f8ccae192dc651801209c05388cf2d1f80340afd1fbb20d6212080010d1c6dad30f1d1ad60d3f2dbfecea406212080110a8cd83f6021da577983e2d1def91c2a2010e080010b7bba4910a50d8e89dc00aca01038c9017", + "token": "A5B55F522A296F443779C0BDA48539A09D2957745DA6E50A745E17A8C7B7ADA5B574637F76AAB8BF73A89A881A494FE5C7B7ADA4B50D682653A7B800D23D9B88B84A346707A4ABADA5B5121E0134AFF57D4D3865AF6FA4A62935B22D2B09E0", + "expected": { + "itemid": "7329690911166954490", + "defindex": 1, + "paintindex": 668, + "paintseed": 3596544175, + "paintwear": 0.5799720287322998, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 4202079057, + "wear": 0.5540481805801392, + "rotation": 7.341399669647217 + }, + { + "slot": 1, + "sticker_id": 784393896, + "wear": 0.2977878153324127, + "rotation": -72.9670181274414 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2720603575, + "pattern": 2819060824 + } + ], + "blobdata": "8c9017" + } + }, + { + "name": "seeded-21", + "protobufHex": "1084cb9b99a48cfaf439183c20df0638b6e59df70340f0d6b2830a5a04f09f94a55a04f09f94a55a0062120800108aa49697091de1a9a03e2de633e3426212080110c6adbaa00a1de32aca3e2db551c0426212080210eda2c5c80e1d30929b3e2dfd2032c3a2010e080010a1b5bdcf0b5092c3c98505ca010e0ce024d630fe5bcd312fb760cd0e", + "token": "FFEF7B3464665B73050BC6E7C3DF20F9C7491A6208FCBF0F294D7CF5A5FB0F606B5AA5FB0F606B5AA5FF9DEDF7FFEF755B6968F6E21E565FC1D219CC1CBD9DEDF7FEEF3952455FF5E21CD535C1D24AAE3FBD9DEDF7FDEF125D3A37F1E2CF6D64C1D202DFCD3C5DFEF1F7FFEF5E4A4230F4AF6D3C367AFA35FEF1F31FDB29CF01A432CED0489F32F1FF864F29", + "expected": { + "itemid": "4173122038466536836", + "defindex": 60, + "paintindex": 863, + "paintseed": 2691476336, + "paintwear": 0.45204704999923706, + "customnames": [ + "🔥", + "🔥", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2464518666, + "wear": 0.3137960731983185, + "rotation": 113.60136413574219 + }, + { + "slot": 1, + "sticker_id": 2752419526, + "wear": 0.3948584496974945, + "rotation": 96.15958404541016 + }, + { + "slot": 2, + "sticker_id": 3910226285, + "wear": 0.3038496971130371, + "rotation": -178.1288604736328 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3119471265, + "pattern": 1353867666 + } + ], + "blobdata": "0ce024d630fe5bcd312fb760cd0e" + } + }, + { + "name": "seeded-22", + "protobufHex": "10dce5b2c9a881b2a7df01180920e70d3883f2c3f70340e7e2ab625a09e697a5e69cace8aa9e5a09e697a5e69cace8aa9e5a0361cc81a2010e0800109f9087ce0550d396bfa306ca010e67191108fcc6138e586b201f90b9", + "token": "0010DCE5B2C9A881B2A7DF01180920E70D3883F2C3F70340E7E2AB625A09E697A5E69CACE8AA9E5A09E697A5E69CACE8AA9E5A0361CC81A2010E0800109F9087CE0550D396BFA306CA010E67191108FCC6138E586B201F90B9F74679A7", + "expected": { + "itemid": "16091018466217997020", + "defindex": 9, + "paintindex": 1767, + "paintseed": 206238055, + "paintwear": 0.4706498086452484, + "customnames": [ + "日本語", + "日本語", + "á" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 1505871903, + "pattern": 1685048147 + } + ], + "blobdata": "67191108fcc6138e586b201f90b9" + } + }, + { + "name": "seeded-23", + "protobufHex": "10e784cd90d3aeff8a8a01180120d20338d2a0ecfb0340e2ccece80d5a0361cc815a0361cc81a2010e0800109dcaad8e05508ee48eb609ca010b061601d9a80eab537e7cbe", + "token": "A5B542216835760B5A2F2FA4BDA48577A69D7705495EA6E54769494DA8FFA6C46924FFA6C4692407A4ABADA5B5386F082BA0F52B412B13AC6FA4AEA3B3A47C0DAB0EF6DBD91B91E3E535", + "expected": { + "itemid": "9950137631538102887", + "defindex": 1, + "paintindex": 466, + "paintseed": 3709544034, + "paintwear": 0.9807177782058716, + "customnames": [ + "á", + "á" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 1372284189, + "pattern": 2529407502 + } + ], + "blobdata": "061601d9a80eab537e7cbe" + } + }, + { + "name": "seeded-24", + "protobufHex": "10f6c7c7edb4a6a8f8a901183c20d008388fdcaff20340e3d7b1a2065a04f09f94a5a2010e080010988cfdd90850aeb8939504ca0106c4c022359818", + "token": "FFEF093838124B59570756FEE7C3DF2FF7C77023500DFCBF1C284E5DF9A5FB0F606B5A5DFEF1F7FFEF67730226F7AF51476C6AFB35FEF93B3FDDCA67E758C48FEF", + "expected": { + "itemid": "12245464628539286518", + "defindex": 60, + "paintindex": 1104, + "paintseed": 1682729955, + "paintwear": 0.19915030896663666, + "customnames": [ + "🔥" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2336179736, + "pattern": 1118100526 + } + ], + "blobdata": "c4c022359818" + } + }, + { + "name": "seeded-25", + "protobufHex": "10f198ca9882d5b8cf4e181020f80238d29295f80340b19abf83095a04f09f94a55a006212080010f49b97f2011d87142c3f2de61b5842621208011082ba8ffa061db288403f2d684e26c262120802109cd2a79f061d10eeee3e2dfedb65c2a2010e08001094d180920750d1aaae8c0bca0100", + "token": "0010F198CA9882D5B8CF4E181020F80238D29295F80340B19ABF83095A04F09F94A55A006212080010F49B97F2011D87142C3F2DE61B5842621208011082BA8FFA061DB288403F2D684E26C262120802109CD2A79F061D10EEEE3E2DFEDB65C2A2010E08001094D180920750D1AAAE8C0BCA010018ACDC22", + "expected": { + "itemid": "5665214593049463921", + "defindex": 16, + "paintindex": 376, + "paintseed": 2423246129, + "paintwear": 0.5206500291824341, + "customnames": [ + "🔥", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 507891188, + "wear": 0.6721882224082947, + "rotation": 54.027244567871094 + }, + { + "slot": 1, + "sticker_id": 1866718466, + "wear": 0.75208580493927, + "rotation": -41.576568603515625 + }, + { + "slot": 2, + "sticker_id": 1676273948, + "wear": 0.4666600227355957, + "rotation": -57.46483612060547 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1916807316, + "pattern": 2978714961 + } + ], + "blobdata": "" + } + }, + { + "name": "seeded-26", + "protobufHex": "1094c297b1ab98caa646180920dd0a38c09aeff80340a4e6f2890b5a005a09e697a5e69cace8aa9e5a04f09f94a56212080010e395c2890c1d9456613f2dc67a97c2a2010e08001099cc86880750f9d5c7bc0dca0100", + "token": "A5B5316732140E3D6F03E3BDAC8578AF9D653F4A5DA6E50143572CAEFFA5FFAC4332004339094D0F3BFFA1553A3100C7B7ADA5B54630672CA9B831F3C49A8863DF326707A4ABADA5B53C69232DA2F55C706219A86FA4A5A307557D", + "expected": { + "itemid": "5065749972606378260", + "defindex": 9, + "paintindex": 1373, + "paintseed": 2973545252, + "paintwear": 0.6086006164550781, + "customnames": [ + "", + "日本語", + "🔥" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 3241183971, + "wear": 0.8802273273468018, + "rotation": -75.73979187011719 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1895933465, + "pattern": 3616664313 + } + ], + "blobdata": "" + } + }, + { + "name": "seeded-27", + "protobufHex": "10d780bbd2cfa480938301180720bd0a3895cff6f90340d3a4d0f00c5a09e697a5e69cace8aa9e6212080010a4b4dcc20e1db7c8a83c2d87d1a1c26212080110e1ec8bb4041dfc7ef93e2d3848f0c26212080210a2aab3a00b1d3fed7d3f2d40282bc3a2010e080010f4b2b6f209508699afb10eca0103674626", + "token": "FFEF287F442D305B7F6C7CFEE7F8DF42F5C76A300906FCBF2C5B2F0FF3A5F619685A1963531755619DEDF7FFEF5B4B233DF1E2483757C3D2782E5E3D9DEDF7FEEF1E13744BFBE2038106C1D2C7B70F3D9DEDF7FDEF5D554C5FF4E2C01282C0D2BFD7D43C5DFEF1F7FFEF0B4D490DF6AF7966504EF135FEFC98B9D9F86B100E", + "expected": { + "itemid": "9450242126413480023", + "defindex": 7, + "paintindex": 1341, + "paintseed": 3457421907, + "paintwear": 0.740838348865509, + "customnames": [ + "日本語" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 3898022436, + "wear": 0.020603520795702934, + "rotation": -80.90923309326172 + }, + { + "slot": 1, + "sticker_id": 1182987873, + "wear": 0.4872969388961792, + "rotation": -120.14105224609375 + }, + { + "slot": 2, + "sticker_id": 3020739874, + "wear": 0.9919013381004333, + "rotation": -171.1572265625 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2655885684, + "pattern": 3861630086 + } + ], + "blobdata": "674626" + } + }, + { + "name": "seeded-28", + "protobufHex": "10e0bfca89edb1e3f7771801209a05389bb9b3fa0340faddace90c6211080010c582c9181d8c451f3f2d58ca21c26212080110a1f6e9d8071de473ff3e2dfdd3a9426212080210fff5d4e4091d9318d43d2daedc5042a2010e080010b392f2920e509986afaa09ca0100", + "token": "0010E0BFCA89EDB1E3F7771801209A05389BB9B3FA0340FADDACE90C6211080010C582C9181D8C451F3F2D58CA21C26212080110A1F6E9D8071DE473FF3E2DFDD3A9426212080210FFF5D4E4091D9318D43D2DAEDC5042A2010E080010B392F2920E509986AFAA09CA01004416BA2F", + "expected": { + "itemid": "8642281854481899488", + "defindex": 1, + "paintindex": 666, + "paintseed": 3442159354, + "paintwear": 0.8002411723136902, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 51528005, + "wear": 0.622154951095581, + "rotation": -40.447601318359375 + }, + { + "slot": 1, + "sticker_id": 2065333025, + "wear": 0.49893105030059814, + "rotation": 84.9140396118164 + }, + { + "slot": 2, + "sticker_id": 2627025663, + "wear": 0.10356249660253525, + "rotation": 52.21550750732422 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3797715251, + "pattern": 2504770329 + } + ], + "blobdata": "" + } + }, + { + "name": "seeded-29", + "protobufHex": "10f7a6e99883ddf68dfb01180920a10838d2b8c8ee0340e7b7b58604a2010e080010f2b9d7e20950d1b5c6c60bca01067f9556014570", + "token": "A5B552034C3D267853285EA4BDAC8504AD9D771D6D4BA6E542121023A107A4ABADA5B5571C7247ACF574106363AE6FA4A3DA30F3A4E0D540F5BD93", + "expected": { + "itemid": "18094296618715730807", + "defindex": 9, + "paintindex": 1057, + "paintseed": 1087200231, + "paintwear": 0.1025930792093277, + "customnames": [], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2622872818, + "pattern": 3100744401 + } + ], + "blobdata": "7f9556014570" + } + }, + { + "name": "seeded-30", + "protobufHex": "10a6d1c5a58da1c8ec36183c20ea0738b4e0f4fb0340aff4b49c055a04f09f94a55a04746573745a0361cc816212080010b783b5cb051d60c9063f2d35283041a2010e080010b6a287a001508087958408ca01040ef40686", + "token": "FFEF592E3A5A725E3713C9E7C3DF15F8C74B1F0B04FCBF500B4B63FAA5FB0F606B5AA5FB8B9A8C8BA5FC9E337E9DEDF7FFEF487C4A34FAE29F36F9C0D2CAD7CFBE5DFEF1F7FFEF495D785FFEAF7F786A7BF735FEFBF10BF9798C7AA463", + "expected": { + "itemid": "3952226469806172326", + "defindex": 60, + "paintindex": 1002, + "paintseed": 1401764399, + "paintwear": 0.9890167713165283, + "customnames": [ + "🔥", + "test", + "á" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 1500332471, + "wear": 0.5265102386474609, + "rotation": 11.00981616973877 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 335663414, + "pattern": 2156217216 + } + ], + "blobdata": "0ef40686" + } + }, + { + "name": "seeded-31", + "protobufHex": "10b8eb85b4e198cf9b79180720860238a9f1e1f00340adffe7655a04746573745a0474657374a2010e080010c888f1c20850add283f702ca010b9b7044904e1c177a93f63e", + "token": "0010B8EB85B4E198CF9B79180720860238A9F1E1F00340ADFFE7655A04746573745A0474657374A2010E080010C888F1C20850ADD283F702CA010B9B7044904E1C177A93F63E3683ECE4", + "expected": { + "itemid": "8734516823787140536", + "defindex": 7, + "paintindex": 262, + "paintseed": 213516205, + "paintwear": 0.14889778196811676, + "customnames": [ + "test", + "test" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2287748168, + "pattern": 786491693 + } + ], + "blobdata": "9b7044904e1c177a93f63e" + } + }, + { + "name": "seeded-32", + "protobufHex": "10dd8093b3efedd5d07b181020aa0538b2efe3f40340aaccece00262120800108ef7a5f90b1de42a553f2d35b317436212080110d5f6eecc021dd94c9e3d2db80e00c1a2010e0800109da896ed0e50accda3c00cca01027755", + "token": "A5B5782536164A487075DEBDB5850FA09D174A4651A6E50F694945A7C7B7ADA5B52B52005CAEB8418FF09A889016B2E6C7B7ADA4B570534B69A7B87CE93B98881DABA56407A4ABADA5B5380D3348ABF509680665A96FA4A7D2F02BD331FD", + "expected": { + "itemid": "8908497672007368797", + "defindex": 16, + "paintindex": 682, + "paintseed": 739976746, + "paintwear": 0.29876476526260376, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 3207166862, + "wear": 0.8326857089996338, + "rotation": 151.7000274658203 + }, + { + "slot": 1, + "sticker_id": 698071893, + "wear": 0.07729501277208328, + "rotation": -8.003593444824219 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3987051549, + "pattern": 3356026540 + } + ], + "blobdata": "7755" + } + }, + { + "name": "seeded-33", + "protobufHex": "10998ff0eea8c9f98035180920de0d38cae0b0eb0340bbf986aa0ca2010d080010a0b3b79b0850f2de921aca01057b50828d31", + "token": "FFEF66700F115736067FCAE7F6DF21F2C7351F4F14FCBF44067955F35DFEF2F7FFEF5F4C4864F7AF0D216DE535FEFA84AF7D72CED7D52C3B", + "expected": { + "itemid": "3819587166868866969", + "defindex": 9, + "paintindex": 1758, + "paintseed": 3309419707, + "paintwear": 0.057663239538669586, + "customnames": [], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 2205014432, + "pattern": 54833010 + } + ], + "blobdata": "7b50828d31" + } + }, + { + "name": "seeded-34", + "protobufHex": "1081f7f0b190cdd2c016180120950a38a0d3b1f903408dd4cea70d62120800109dc7cc86051d46482e3e2dad14e4c2a2010e080010e980a9820b50fcbc9e9c01ca010fef908e7c61cb116e4e39bde063a443", + "token": "001081F7F0B190CDD2C016180120950A38A0D3B1F903408DD4CEA70D62120800109DC7CC86051D46482E3E2DAD14E4C2A2010E080010E980A9820B50FCBC9E9C01CA010FEF908E7C61CB116E4E39BDE063A44316C082DB", + "expected": { + "itemid": "1621659155766721409", + "defindex": 1, + "paintindex": 1301, + "paintseed": 3572738573, + "paintwear": 0.6734867095947266, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 1356014493, + "wear": 0.17019757628440857, + "rotation": -114.0403823852539 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2957656169, + "pattern": 327655036 + } + ], + "blobdata": "ef908e7c61cb116e4e39bde063a443" + } + }, + { + "name": "seeded-35", + "protobufHex": "1093a6fd909b9e88acb6011809209c0538a7bbadf8034087c6b0d1066211080010b393f31b1da96d103f2d9e5929c2a2010e080010c79085850c50d4a5f1a208ca010a9078401ed5a3f3a15241", + "token": "A5B5360358353E3B2D0913A4BDAC8539A09D021E085DA6E522631574A3C7B4ADA5B5163656BEB80CC8B59A883BFC8C6707A4ABADA5B562352020A9F571005407AD6FA4AF35DDE5BB70065604F7E42EF70B9D", + "expected": { + "itemid": "13139288135301026579", + "defindex": 9, + "paintindex": 668, + "paintseed": 1781277447, + "paintwear": 0.54439777135849, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 58509747, + "wear": 0.5641732811927795, + "rotation": -42.33751678466797 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3231795271, + "pattern": 2220643028 + } + ], + "blobdata": "9078401ed5a3f3a15241" + } + }, + { + "name": "seeded-36", + "protobufHex": "10f4e1bcfcc5b193b89401181020aa0d389981a3f80340bfd7e985075a0361cc815a006212080010b48987c70a1d67ee1d3f2d9a8203436212080110c7e0a58a0a1d144f673f2dce399242a2010e0800109eb2d2cb07509bd6cefb04ca010cfdcdb7c3b85dc58e908cc03b", + "token": "FFEF0B1E43033A4E6C476BFEE7EFDF55F2C7667E5C07FCBF4028167AF8A5FC9E337EA5FF9DEDF7FFEF4B767838F5E29811E2C0D2657DFCBC9DEDF7FEEF381F5A75F5E2EBB098C0D231C66DBD5DFEF1F7FFEF614D2D34F8AF64293104FB35FEF30232483C47A23A716F733FC437076615", + "expected": { + "itemid": "10696134380298907892", + "defindex": 16, + "paintindex": 1706, + "paintseed": 1891265471, + "paintwear": 0.5341888070106506, + "customnames": [ + "á", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2833368244, + "wear": 0.6169189810752869, + "rotation": 131.51016235351562 + }, + { + "slot": 1, + "sticker_id": 2705944647, + "wear": 0.903550386428833, + "rotation": 73.11289978027344 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2037684510, + "pattern": 1332980507 + } + ], + "blobdata": "fdcdb7c3b85dc58e908cc03b" + } + }, + { + "name": "seeded-37", + "protobufHex": "10a8adbe8af98b9be53e180920ae0a38f2869afa03409ed5bdcf0a5a09e697a5e69cace8aa9e5a0361cc81621208001087c1bb9f0f1d4bec013f2d367f0d436212080110c48980c20a1d8dffe53e2d8c3a29c3a2010e080010cec8cac80650aaefa0d102ca0104d8958bc2", + "token": "0010A8ADBE8AF98B9BE53E180920AE0A38F2869AFA03409ED5BDCF0A5A09E697A5E69CACE8AA9E5A0361CC81621208001087C1BB9F0F1D4BEC013F2D367F0D436212080110C48980C20A1D8DFFE53E2D8C3A29C3A2010E080010CEC8CAC80650AAEFA0D102CA0104D8958BC2F3A02AE2", + "expected": { + "itemid": "4524547933362689704", + "defindex": 9, + "paintindex": 1326, + "paintseed": 2851039902, + "paintwear": 0.7754431962966919, + "customnames": [ + "日本語", + "á" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 4092518535, + "wear": 0.5075117945671082, + "rotation": 141.49691772460938 + }, + { + "slot": 1, + "sticker_id": 2822767812, + "wear": 0.4492153227329254, + "rotation": -169.22869873046875 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1762829390, + "pattern": 707278762 + } + ], + "blobdata": "d8958bc2" + } + }, + { + "name": "seeded-38", + "protobufHex": "10d8e7c5acc7a6b69b76180920c80238c9829ef80340f5b0ddcb045a005a0361cc815a006211080010e98796121d2611563b2d5a155541a2010e08001084b2e3ba0650fc81b9c50dca010516f3a0e762", + "token": "A5B57D4260096203133ED3BDAC856DA79D6C273B5DA6E55015786EA1FFA5FFA6C46924FFA5C7B4ADA5B54C2233B7B883B4F39E88FFB0F0E407A4ABADA5B52117461FA3F559241C60A86FA4A0B3560542C7221A0FD0", + "expected": { + "itemid": "8518234564551865304", + "defindex": 9, + "paintindex": 328, + "paintseed": 1232558197, + "paintwear": 0.5293164849281311, + "customnames": [ + "", + "á", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 38110185, + "wear": 0.0032664029859006405, + "rotation": 13.317712783813477 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1733875972, + "pattern": 3635298556 + } + ], + "blobdata": "16f3a0e762" + } + }, + { + "name": "seeded-39", + "protobufHex": "10c9bac28ee1dccaa2fc01180120d20b38c9c4c4dd0340a6e1a6f50a5a09e697a5e69cace8aa9e6212080010d0add588051d38b36f3f2d8f1303436212080110e99ffaf30a1d26b2163f2d98703343a2010e080010fcb7b1c60a5091b1c0830eca0102979c", + "token": "FFEF36453D711E23355D03FEE7FEDF2DF4C7363B3B22FCBF591E590AF5A5F619685A1963531755619DEDF7FFEF2F522A77FAE2C74C90C0D270ECFCBC9DEDF7FEEF1660050CF5E2D94DE9C0D2678FCCBC5DFEF1F7FFEF03484E39F5AF6E4E3F7CF135FEFD6863E688EF1B", + "expected": { + "itemid": "18177982638580604233", + "defindex": 1, + "paintindex": 1490, + "paintseed": 2930356390, + "paintwear": 0.0054056984372437, + "customnames": [ + "日本語" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 1360352976, + "wear": 0.9363284111022949, + "rotation": 131.07640075683594 + }, + { + "slot": 1, + "sticker_id": 2927529961, + "wear": 0.5886558294296265, + "rotation": 179.4398193359375 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 2831965180, + "pattern": 3765442705 + } + ], + "blobdata": "979c" + } + }, + { + "name": "seeded-40", + "protobufHex": "10f891e5aca9dce687b801180120fe0738cf95edf90340a3e79296056212080010b3f6cbb40b1d8bf90a3f2d7bc6a0c2a2010d080010eed8ca9e0450f2fe904aca01084287b2b377d1e43d", + "token": "0010F891E5ACA9DCE687B801180120FE0738CF95EDF90340A3E79296056212080010B3F6CBB40B1D8BF90A3F2D7BC6A0C2A2010D080010EED8CA9E0450F2FE904ACA01084287B2B377D1E43D77CAFC32", + "expected": { + "itemid": "13262989725592537336", + "defindex": 1, + "paintindex": 1022, + "paintseed": 1388622755, + "paintwear": 0.7316102385520935, + "customnames": [], + "stickers": [ + { + "slot": 0, + "sticker_id": 3063085875, + "wear": 0.5428702235221863, + "rotation": -80.38765716552734 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 1137880174, + "pattern": 155467634 + } + ], + "blobdata": "4287b2b377d1e43d" + } + }, + { + "name": "seeded-41", + "protobufHex": "10adf7ba8b88a380d4bd01183c209d0638b2e7f5f20340ebacdef90e5a04f09f94a55a09e697a5e69cace8aa9e5a04746573746212080010e0ac9889081d8e30843e2d74a2c7c2a2010e080010f4e5c3a602508aedab9a0dca0109b8612caeec4f72910a", + "token": "A5B508521F2E2D06257118A4BD998538A39D17425057A6E54E097B5CABFFA1553A3100FFAC4332004339094D0F3BFFA1D1C0D6D1C7B7ADA5B545093D2CADB82B95219B88D107626707A4ABADA5B551406603A7F52F480E3FA86FA4AC1DC4890B49EAD734AF0AD8207E", + "expected": { + "itemid": "13666174274018130861", + "defindex": 60, + "paintindex": 797, + "paintseed": 4013397611, + "paintwear": 0.21626165509223938, + "customnames": [ + "🔥", + "日本語", + "test" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2166756960, + "wear": 0.25818294286727905, + "rotation": -99.81729125976562 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 617673460, + "pattern": 3544905354 + } + ], + "blobdata": "b8612caeec4f72910a" + } + }, + { + "name": "seeded-42", + "protobufHex": "10afdd90cbe6c2a8d70a180720ce0338948bfef60340a0bf9b92015a04f09f94a55a0361cc8162120800108baceebe071d980d673f2db79717c36211080110d6f79b0f1d0e08fc3e2dfaea87c2a2010e080010f98abfb90c508997f29206ca01040663a7c9", + "token": "FFEF50226F34193D5728F5E7F8DF31FCC76B740109FCBF5F40646DFEA5FB0F606B5AA5FC9E337E9DEDF7FFEF74531141F8E267F298C0D24868E83C9DEEF7FEEF290864F0E2F1F703C1D20515783D5DFEF1F7FFEF06754046F3AF76680D6DF935FEFBF99C583610115C9B", + "expected": { + "itemid": "769730803468086959", + "defindex": 7, + "paintindex": 462, + "paintseed": 306634656, + "paintwear": 0.43656599521636963, + "customnames": [ + "🔥", + "á" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2010879499, + "wear": 0.9025511741638184, + "rotation": -151.59263610839844 + }, + { + "slot": 1, + "sticker_id": 31914966, + "wear": 0.4922489523887634, + "rotation": -67.95893859863281 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3341796729, + "pattern": 1650232201 + } + ], + "blobdata": "0663a7c9" + } + }, + { + "name": "seeded-43", + "protobufHex": "10a3dcf7c0f591eaaa5b180720cc0f38d5d6ddf80340ceffcfdc015a0361cc81a2010e080010ba9d85ec065095fc89b20dca0102289f", + "token": "0010A3DCF7C0F591EAAA5B180720CC0F38D5D6DDF80340CEFFCFDC015A0361CC81A2010E080010BA9D85EC065095FC89B20DCA0102289F8CDB3580", + "expected": { + "itemid": "6581351764083994147", + "defindex": 7, + "paintindex": 1996, + "paintseed": 462684110, + "paintwear": 0.5914815068244934, + "customnames": [ + "á" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 1837190842, + "pattern": 3594681877 + } + ], + "blobdata": "289f" + } + }, + { + "name": "seeded-44", + "protobufHex": "1091a6dbc993f8cf8fd201183c20c60a38fc82cffa0340fb8bc192035a04f09f94a55a04f09f94a56212080010f3be8ad10f1d7a68563f2d79b69ac26212080110e5c1c2d30c1d6ba0373f2de9e81fc36212080210a9b4ca90051d7dace43e2db73cfd42a2010e080010b2a18a9b0150dca7c8b90cca010c639368e54ddedd3707490626", + "token": "A5B534037E6C365D6A2A77A4BD998563AF9D59276A5FA6E55E2E6437A6FFA1553A3100FFA1553A3100C7B7ADA5B5561B2F74AAB8DFCDF39A88DC133F67C7B7ADA4B540646776A9B8CE05929A884C4DBA66C7B7ADA7B50C116F35A0B8D809419B88129958E707A4ABADA5B517042F3EA4F579026D1CA96FA4A9C636CD40E87B7892A2ECA3838C66E6DC", + "expected": { + "itemid": "15140890571364029201", + "defindex": 60, + "paintindex": 1350, + "paintseed": 844121595, + "paintwear": 0.8271710872650146, + "customnames": [ + "🔥", + "🔥" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 4196573043, + "wear": 0.8375316858291626, + "rotation": -77.35639190673828 + }, + { + "slot": 1, + "sticker_id": 3396378853, + "wear": 0.717291533946991, + "rotation": -159.90980529785156 + }, + { + "slot": 2, + "sticker_id": 1376950825, + "wear": 0.44662848114967346, + "rotation": 126.61858367919922 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 325226674, + "pattern": 3341947868 + } + ], + "blobdata": "639368e54ddedd3707490626" + } + }, + { + "name": "seeded-45", + "protobufHex": "10fbe8e699ece6b3c88901180720d80538a7cefff70340e4be97c10b5a04746573745a0062120800109087cae5091d3e306d3f2de806ffc262120801108694e3b9041db2d6383f2d881b04c2a2010e080010f5a9b4ff0b50e3fccff90bca010747383a505249d8", + "token": "FFEF0417196613194C3776FEE7F8DF27FAC758310008FCBF1B41683EF4A5FB8B9A8C8BA5FF9DEDF7FFEF6F78351AF6E2C1CF92C0D217F9003D9DEDF7FEEF796B1C46FBE24D29C7C0D277E4FB3D5DFEF1F7FFEF0A564B00F4AF1C033006F435FEF8B8C7C5AFADB627C0C84AC9", + "expected": { + "itemid": "9912650613952984187", + "defindex": 7, + "paintindex": 728, + "paintseed": 3089489764, + "paintwear": 0.49981042742729187, + "customnames": [ + "test", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 2628944784, + "wear": 0.9265173673629761, + "rotation": -127.51348876953125 + }, + { + "slot": 1, + "sticker_id": 1194904070, + "wear": 0.722025990486145, + "rotation": -33.026885986328125 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3219985653, + "pattern": 3207855715 + } + ], + "blobdata": "47383a505249d8" + } + }, + { + "name": "seeded-46", + "protobufHex": "10befea0f8b2cd9ab846183c20d1043884baf8f0034083afcbc8095a005a00621208001099e280f90d1dab64073f2d22e5f8c262120801109aa3daeb051d0373113f2d398a90c26212080210d9c6d2fd081d5355e33e2d19c5c4c2a2010e08001098da90b70b50fecd8fd104ca010fc73dc9ed0ca8924c359dd819275ead", + "token": "0010BEFEA0F8B2CD9AB846183C20D1043884BAF8F0034083AFCBC8095A005A00621208001099E280F90D1DAB64073F2D22E5F8C262120801109AA3DAEB051D0373113F2D398A90C26212080210D9C6D2FD081D5355E33E2D19C5C4C2A2010E08001098DA90B70B50FECD8FD104CA010FC73DC9ED0CA8924C359DD819275EAD61D56D6F", + "expected": { + "itemid": "5075673788629663550", + "defindex": 60, + "paintindex": 593, + "paintseed": 2568148867, + "paintwear": 0.1544075608253479, + "customnames": [ + "", + "" + ], + "stickers": [ + { + "slot": 0, + "sticker_id": 3743428889, + "wear": 0.5288798213005066, + "rotation": -124.44752502441406 + }, + { + "slot": 1, + "sticker_id": 1568051610, + "wear": 0.5681611895561218, + "rotation": -72.26996612548828 + }, + { + "slot": 2, + "sticker_id": 2410980185, + "wear": 0.44401034712791443, + "rotation": -98.38495635986328 + } + ], + "keychains": [ + { + "slot": 0, + "sticker_id": 3068407064, + "pattern": 1243866878 + } + ], + "blobdata": "c73dc9ed0ca8924c359dd819275ead" + } + }, + { + "name": "seeded-47", + "protobufHex": "10d988b6e8dbcc98899f011810200538e1e7fdf10340e186ecc2095a00a2010e080010ce99fbbc0150cfe4d5e101ca010b485a235c1bf0f757b1f828", + "token": "A5B57C2D134D7E693D2C3AA4BDB585A09D44425854A6E544234967ACFFA507A4ABADA5B56B3C5E19A4F56A417044A46FA4AEEDFF86F9BE5552F2145D8D8CADB7E5", + "expected": { + "itemid": "11462332190714332249", + "defindex": 16, + "paintindex": 5, + "paintseed": 2556101473, + "paintwear": 0.1869654804468155, + "customnames": [ + "" + ], + "stickers": [], + "keychains": [ + { + "slot": 0, + "sticker_id": 396283086, + "pattern": 473264719 + } + ], + "blobdata": "485a235c1bf0f757b1f828" + } + } + ], + "malformed": [ + { + "name": "truncated-uint64", + "protobufHex": "1080", + "token": "001080B076D8A5" + }, + { + "name": "truncated-string", + "protobufHex": "10015a056162", + "token": "0010015A05616239E59496" + }, + { + "name": "truncated-attachment", + "protobufHex": "1001620308", + "token": "0010016203082429781C" + }, + { + "name": "truncated-fixed32", + "protobufHex": "100162021d00", + "token": "00100162021D00D4B0C5B5" + }, + { + "name": "invalid-wire-type", + "protobufHex": "10010f", + "token": "0010010F1E52A288" + } + ] +} diff --git a/handlers.js b/handlers.js index 443af09..9ad94c8 100644 --- a/handlers.js +++ b/handlers.js @@ -17,18 +17,18 @@ const handlers = NodeCS2.prototype._handlers; */ NodeCS2.prototype._mapStickerLikeItem = function (stickerLike) { return { - slot: stickerLike.slot || 0, - sticker_id: stickerLike.sticker_id || 0, - wear: stickerLike.wear || null, - scale: stickerLike.scale || null, - rotation: stickerLike.rotation || null, - tint_id: stickerLike.tint_id || null, - offset_x: stickerLike.offset_x || null, - offset_y: stickerLike.offset_y || null, - offset_z: stickerLike.offset_z || null, - pattern: stickerLike.pattern || null, - highlight_reel: stickerLike.highlight_reel || null, - wrapped_sticker: stickerLike.wrapped_sticker || null + slot: stickerLike.slot ?? 0, + sticker_id: stickerLike.sticker_id ?? 0, + wear: stickerLike.wear ?? null, + scale: stickerLike.scale ?? null, + rotation: stickerLike.rotation ?? null, + tint_id: stickerLike.tint_id ?? null, + offset_x: stickerLike.offset_x ?? null, + offset_y: stickerLike.offset_y ?? null, + offset_z: stickerLike.offset_z ?? null, + pattern: stickerLike.pattern ?? null, + highlight_reel: stickerLike.highlight_reel ?? null, + wrapped_sticker: stickerLike.wrapped_sticker ?? null }; }; @@ -77,7 +77,9 @@ handlers[Language.ClientWelcome] = function (body) { }) .filter((item) => item !== null); - this.inventory = items; + this.inventory = Array.from(new Map(items.map((item) => [item.id, item])).values()); + const version = proto.outofdate_subscribed_caches[0].version; + this._soVersions = new Map(version == null ? [] : this.inventory.map((item) => [item.id, BigInt(version)])); break; /*case 7: // Account metadata - this doesn't appear to be useful in CS:GO @@ -125,7 +127,7 @@ handlers[Language.ClientConnectionStatus] = function (body) { return; } - if (!proto || typeof proto.status === 'undefined') { + if (!proto || proto.status == null) { this.emit('debug', 'ClientConnectionStatus missing status field'); return; } @@ -145,6 +147,7 @@ handlers[Language.ClientConnectionStatus] = function (body) { ); if (proto.status != NodeCS2.GCConnectionStatus.HAVE_SESSION && this.haveGCSession) { + this.cancelPendingRequests('DISCONNECTED'); this.emit('disconnectedFromGC', proto.status); this.haveGCSession = false; this._connect(); // Try to reconnect @@ -178,17 +181,15 @@ handlers[Language.PlayersProfile] = function (body) { return; } - const profile = proto.account_profiles[0]; - - if (!profile.account_id) { - this.emit('debug', 'PlayersProfile missing account_id'); - return; + for (const profile of proto.account_profiles) { + if (!profile.account_id) { + this.emit('debug', 'PlayersProfile missing account_id'); + continue; + } + const sid = SteamID.fromIndividualAccountID(profile.account_id); + this.emit('playersProfile', profile); + this.emit('playersProfile#' + sid.getSteamID64(), profile); } - - const sid = SteamID.fromIndividualAccountID(profile.account_id); - - this.emit('playersProfile', profile); - this.emit('playersProfile#' + sid.getSteamID64(), profile); }; // Inspecting items @@ -209,7 +210,7 @@ handlers[Language.Client2GCEconPreviewDataBlockResponse] = function (body) { const item = proto.iteminfo; // Validate critical fields - if (typeof item.itemid === 'undefined') { + if (item.itemid == null) { this.emit('debug', 'Item inspection missing itemid'); return; } @@ -294,17 +295,18 @@ handlers[Language.MatchmakingGC2ClientSearchStats] = function (body) { // Item manipulation handlers[Language.CraftResponse] = function (body) { - const blueprint = body.readInt16(); // recipe ID - const unknown = body.readUint32(); // always 0 in my experience - - const idCount = body.readUint16(); - const idList = []; // let's form an array of IDs - - for (let i = 0; i < idCount; i++) { - const id = body.readUint64().toString(); // grab the next id - idList.push(id); // item id + let blueprint; + const idList = []; + try { + blueprint = body.readInt16(); + body.readUint32(); // reserved + const idCount = body.readUint16(); + if (body.remaining() < idCount * 8) throw new Error('Truncated item IDs'); + for (let i = 0; i < idCount; i++) idList.push(body.readUint64().toString()); + } catch (error) { + this.emit('error', new Error(`Failed to decode CraftResponse: ${error.message}`)); + return; } - this.emit('craftingComplete', blueprint, idList); }; @@ -335,7 +337,7 @@ NodeCS2.prototype._processSOEconItem = function (item) { const casketIdLow = getAttributeValueBytes(Constants.ATTRIB_CASKET_ID_LOW); const casketIdHigh = getAttributeValueBytes(Constants.ATTRIB_CASKET_ID_HIGH); if (casketIdLow && casketIdHigh) { - const casketIdLong = new Long(casketIdLow.readUInt32LE(0), casketIdHigh.readUInt32LE(0)); + const casketIdLong = new Long(casketIdLow.readUInt32LE(0), casketIdHigh.readUInt32LE(0), true); item.casket_id = casketIdLong.toString(); } @@ -442,7 +444,8 @@ NodeCS2.prototype._processSOEconItem = function (item) { */ function getAttributeValueBytes(attribDefIndex) { const attrib = (item.attribute || []).find((attrib) => attrib.def_index == attribDefIndex); - return attrib ? attrib.value_bytes : null; + const bytes = attrib ? attrib.value_bytes : null; + return bytes && bytes.length >= (attribDefIndex === Constants.ATTRIB_CUSTOM_NAME ? 2 : 4) ? bytes : null; } }; @@ -474,7 +477,12 @@ NodeCS2.prototype._handleSOCreate = function (proto) { return; } + if (!item || !item.id) return; + if (this.inventory.some((entry) => entry.id === item.id)) return this._handleSOUpdate(proto); + if (!this._acceptSOVersion(item.id, proto.version)) return; this._processSOEconItem(item); + const existing = this.inventory.findIndex((entry) => entry.id === item.id); + if (existing !== -1) return; this.inventory.push(item); this.emit('itemAcquired', item); @@ -513,11 +521,18 @@ NodeCS2.prototype._handleSOUpdate = function (so) { return; } + if (!this._acceptSOVersion(item.id, so.version)) return; this._processSOEconItem(item); + if (!this.inventory.some((entry) => entry.id === item.id)) { + this.inventory.push(item); + this.emit('itemAcquired', item); + return; + } for (let i = 0; i < this.inventory.length; i++) { if (this.inventory[i].id == item.id) { const oldItem = this.inventory[i]; + if (require('util').isDeepStrictEqual(oldItem, item)) return; this.inventory[i] = item; this.emit('itemChanged', oldItem, item); @@ -559,6 +574,7 @@ NodeCS2.prototype._handleSODestroy = function (proto) { return; } + if (!this._acceptSOVersion(item.id, proto.version)) return; item.id = item.id.toString(); let itemData = null; for (let i = 0; i < this.inventory.length; i++) { @@ -569,7 +585,7 @@ NodeCS2.prototype._handleSODestroy = function (proto) { } } - this.emit('itemRemoved', itemData); + if (itemData) this.emit('itemRemoved', itemData); }; handlers[Language.SO_UpdateMultiple] = function (body) { @@ -581,9 +597,7 @@ handlers[Language.SO_UpdateMultiple] = function (body) { return; } - (proto.objects_added || []).forEach((item) => this._handleSOCreate(item)); - (proto.objects_modified || []).forEach((item) => this._handleSOUpdate(item)); - (proto.objects_removed || []).forEach((item) => this._handleSODestroy(item)); + (proto.objects_modified || []).forEach((item) => this._handleSOUpdate({ ...item, version: proto.version })); }; NodeCS2.prototype._normalizeInspectItem = function (item) { @@ -592,7 +606,7 @@ NodeCS2.prototype._normalizeInspectItem = function (item) { item.customname = item.customnames.length ? item.customnames[item.customnames.length - 1] : null; // decode the wear - if (typeof item.paintwear !== 'undefined') { + if (item.paintwear != null) { const buf = Buffer.alloc(4); buf.writeUInt32BE(item.paintwear, 0); item.paintwear = buf.readFloatBE(0); @@ -615,3 +629,14 @@ NodeCS2.prototype._normalizeInspectItem = function (item) { return item; }; + +// Compare decimal uint64 cache versions without losing precision. Track tombstones +// so an old create cannot resurrect a destroyed item within the current cache. +NodeCS2.prototype._acceptSOVersion = function (id, version) { + if (version == null) return true; + this._soVersions = this._soVersions || new Map(); + const previous = this._soVersions.get(String(id)); + if (previous !== undefined && BigInt(version) <= previous) return false; + this._soVersions.set(String(id), BigInt(version)); + return true; +}; diff --git a/index.js b/index.js index 22ce6f1..ad3affb 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,8 @@ const Language = require('./language.js'); const Protos = require('./protobufs/generated/_load.js'); const Constants = require('./constants.js'); const decodeInspectLink = require('./lib/inspect-link.js'); +const { request, RequestError } = require('./lib/request.js'); +NodeCS2.RequestError = RequestError; const STEAM_APPID = Constants.STEAM_APPID; @@ -30,8 +32,13 @@ function NodeCS2(steam) { this._steam = steam; this.haveGCSession = false; this._isInCSGO = false; + this._transportListeners = []; + const listen = (event, listener) => { + this._transportListeners.push([event, listener]); + this._steam.on(event, listener); + }; - this._steam.on('receivedFromGC', (appid, msgType, payload) => { + listen('receivedFromGC', (appid, msgType, payload) => { if (appid != STEAM_APPID) { return; // we don't care } @@ -60,7 +67,7 @@ function NodeCS2(steam) { } }); - this._steam.on('appLaunched', (appid) => { + listen('appLaunched', (appid) => { if (this._isInCSGO) { return; // we don't care if it was launched again } @@ -74,6 +81,9 @@ function NodeCS2(steam) { }); const handleAppQuit = (emitDisconnectEvent) => { + clearTimeout(this._helloTimer); + this._helloTimer = null; + this.cancelPendingRequests('DISCONNECTED'); if (this._helloInterval) { clearInterval(this._helloInterval); this._helloInterval = null; @@ -87,7 +97,7 @@ function NodeCS2(steam) { this.haveGCSession = false; }; - this._steam.on('appQuit', (appid) => { + listen('appQuit', (appid) => { if (!this._isInCSGO) { return; } @@ -97,11 +107,11 @@ function NodeCS2(steam) { } }); - this._steam.on('disconnected', () => { + listen('disconnected', () => { handleAppQuit(true); }); - this._steam.on('error', (err) => { + listen('error', (err) => { handleAppQuit(true); }); } @@ -147,7 +157,7 @@ NodeCS2.prototype.helloGC = function () { }; NodeCS2.prototype._send = function (type, protobuf, body) { - if (!this._steam.steamID) { + if (this._disposed || !this._steam.steamID) { return false; } @@ -258,82 +268,67 @@ NodeCS2.prototype.inspectItem = function (owner, assetid, d, callback) { } let match; - if (typeof owner === 'string' && (match = owner.match(/[SM](\d+)A(\d+)D(\d+)$/))) { + let ownerKind; + if (typeof owner === 'string' && (match = owner.match(/([SM])(\d+)A(\d+)D(\d+)$/))) { callback = assetid; - owner = match[1]; - assetid = match[2]; - d = match[3]; - } - - const msg = { - param_a: assetid, - param_d: d, - param_s: 0, - param_m: 0 + ownerKind = match[1]; + owner = match[2]; + assetid = match[3]; + d = match[4]; + } + + // protobufjs coerces and wraps invalid integers; reject before any send and use + // canonical keys so a response without leading zeroes reaches its request. + const uint64 = (value, field) => { + if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) value = String(value); + if (typeof value !== 'string' || !/^\d+$/.test(value) || BigInt(value) > 18446744073709551615n) { + throw new RangeError(`${field} must be a uint64 decimal string or a safe non-negative integer`); + } + return BigInt(value).toString(); }; - - if (typeof owner === 'object') { - owner = owner.toString(); - } - + if (owner && typeof owner === 'object') owner = owner.toString(); + owner = uint64(owner, 'owner'); + assetid = uint64(assetid, 'assetid'); + d = uint64(d, 'd'); + const msg = { param_a: assetid, param_d: d, param_s: 0, param_m: 0 }; + let validSteamID = false; try { const sid = new SteamID(owner); - if ( - !sid.isValid() || - sid.universe != SteamID.Universe.PUBLIC || - sid.type != SteamID.Type.INDIVIDUAL || - sid.instance != SteamID.Instance.DESKTOP - ) { - throw 0; - } - // it's a valid steamid - msg.param_s = owner; - } catch (e) { - msg.param_m = owner; - } - - this._send( - Language.Client2GCEconPreviewDataBlockRequest, - Protos.CMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockRequest, - msg - ); - - // Support both callback and Promise-based API - if (callback) { - const listener = (item) => { - clearTimeout(timeout); - callback(item); - }; - const timeout = setTimeout(() => { - this.removeListener('inspectItemInfo#' + assetid, listener); - this.emit('inspectItemTimedOut', assetid); - this.emit('inspectItemTimedOut#' + assetid, assetid); - }, this._inspectTimeout || Constants.INSPECT_ITEM_TIMEOUT_MS); - - this.once('inspectItemInfo#' + assetid, listener); - } else { - // Return Promise when no callback provided - return new Promise((resolve, reject) => { - const successListener = (item) => { - clearTimeout(timeout); - this.removeListener('inspectItemTimedOut#' + assetid, timeoutListener); - resolve(item); - }; - const timeoutListener = () => { - this.removeListener('inspectItemInfo#' + assetid, successListener); - reject(new Error(`Inspect item timed out for assetid: ${assetid}`)); - }; - - const timeout = setTimeout(() => { - this.removeListener('inspectItemInfo#' + assetid, successListener); + validSteamID = + sid.isValid() && + sid.universe === SteamID.Universe.PUBLIC && + sid.type === SteamID.Type.INDIVIDUAL && + sid.instance === SteamID.Instance.DESKTOP; + } catch (error) { + /* Non-SteamID uint64 values identify market listings. */ + } + if (ownerKind === 'S' && !validSteamID) throw new RangeError('S owner must be a valid individual SteamID'); + if (ownerKind === 'M' || (!ownerKind && !validSteamID)) msg.param_m = owner; + else msg.param_s = owner; + + return request( + this, + { + key: 'inspect:' + assetid, + event: 'inspectItemInfo#' + assetid, + timeout: this._inspectTimeout || Constants.INSPECT_ITEM_TIMEOUT_MS, + message: `Inspect item timed out for assetid: ${assetid}`, + onTimeout: () => { this.emit('inspectItemTimedOut', assetid); this.emit('inspectItemTimedOut#' + assetid, assetid); - }, this._inspectTimeout || Constants.INSPECT_ITEM_TIMEOUT_MS); - - this.once('inspectItemInfo#' + assetid, successListener); - this.once('inspectItemTimedOut#' + assetid, timeoutListener); - }); - } + }, + send: () => + this._send( + Language.Client2GCEconPreviewDataBlockRequest, + Protos.CMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockRequest, + msg + ) + }, + callback && + ((error, item) => { + if (!error) callback(item); + }) + ); }; NodeCS2.prototype.requestPlayersProfile = function (steamid, callback) { @@ -354,27 +349,22 @@ NodeCS2.prototype.requestPlayersProfile = function (steamid, callback) { return Promise.reject(new Error('Invalid SteamID')); } - this._send(Language.ClientRequestPlayersProfile, Protos.CMsgGCCStrike15_v2_ClientRequestPlayersProfile, { - account_id: steamid.accountid, - request_level: Constants.PLAYERS_PROFILE_REQUEST_LEVEL - }); - - if (callback) { - this.once('playersProfile#' + steamid.getSteamID64(), callback); - } else { - // Return Promise when no callback provided - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('playersProfile#' + steamid.getSteamID64(), resolve); - reject(new Error(`Request players profile timed out for SteamID: ${steamid.getSteamID64()}`)); - }, this._profileTimeout || Constants.PROFILE_TIMEOUT_MS); - - this.once('playersProfile#' + steamid.getSteamID64(), (profile) => { - clearTimeout(timeout); - resolve(profile); - }); - }); - } + return request( + this, + { + key: 'profile:' + steamid.getSteamID64(), + event: 'playersProfile#' + steamid.getSteamID64(), + mutation: false, + timeout: this._profileTimeout || Constants.PROFILE_TIMEOUT_MS, + message: 'requestPlayersProfile timed out', + send: () => + this._send(Language.ClientRequestPlayersProfile, Protos.CMsgGCCStrike15_v2_ClientRequestPlayersProfile, { + account_id: steamid.accountid, + request_level: Constants.PLAYERS_PROFILE_REQUEST_LEVEL + }) + }, + callback && ((error, value) => callback(error || value)) + ); }; /** @@ -451,7 +441,7 @@ NodeCS2.prototype.removeFromCasket = function (casketId, itemId) { */ NodeCS2.prototype.getCasketContents = function (casketId, callback) { // First see if we already have this casket's contents in our inventory - const casketItem = this.inventory.find((item) => item.id == casketId); + const casketItem = (this.inventory || []).find((item) => item.id == casketId); if (!casketItem) { const error = new Error(`No casket matching ID ${casketId} was found`); if (callback) { @@ -480,80 +470,24 @@ NodeCS2.prototype.getCasketContents = function (casketId, callback) { } // We need to load casket contents from the GC - this._send(Language.CasketItemLoadContents, Protos.CMsgCasketItem, { - casket_item_id: casketId, - item_item_id: casketId - }); - - // Set a timeout in case the GC isn't being cooperative (configurable via _casketTimeout) - let timedOut = false; - let timeout; - let customizationNotification; - - if (callback) { - // Callback-based API - timeout = setTimeout(() => { - if (timedOut) { - return; - } - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - callback(new Error('Loading casket contents timed out')); - }, this._casketTimeout || Constants.CASKET_TIMEOUT_MS); - - customizationNotification = (itemIds, notificationType) => { - if (timedOut) { - this.off('itemCustomizationNotification', customizationNotification); - return; - } - - if (itemIds[0] != casketId || notificationType != NodeCS2.ItemCustomizationNotification.CasketContents) { - return; - } - - // This is our casket, and it's the correct notification - clearTimeout(timeout); - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - callback( - null, - this.inventory.filter((item) => item.casket_id == casketId) - ); - }; - - this.on('itemCustomizationNotification', customizationNotification); - } else { - // Promise-based API - return new Promise((resolve, reject) => { - timeout = setTimeout(() => { - if (timedOut) { - return; - } - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - reject(new Error('Loading casket contents timed out')); - }, this._casketTimeout || Constants.CASKET_TIMEOUT_MS); - - customizationNotification = (itemIds, notificationType) => { - if (timedOut) { - this.off('itemCustomizationNotification', customizationNotification); - return; - } - - if (itemIds[0] != casketId || notificationType != NodeCS2.ItemCustomizationNotification.CasketContents) { - return; - } - - // This is our casket, and it's the correct notification - clearTimeout(timeout); - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - resolve(this.inventory.filter((item) => item.casket_id == casketId)); - }; - - this.on('itemCustomizationNotification', customizationNotification); - }); - } + return request( + this, + { + key: 'item:' + casketId, + event: 'itemCustomizationNotification', + timeout: this._casketTimeout || Constants.CASKET_TIMEOUT_MS, + message: 'Loading casket contents timed out', + match: (ids, type) => + ids[0] === String(casketId) && type === NodeCS2.ItemCustomizationNotification.CasketContents, + map: () => this.inventory.filter((item) => item.casket_id == casketId), + send: () => + this._send(Language.CasketItemLoadContents, Protos.CMsgCasketItem, { + casket_item_id: casketId, + item_item_id: casketId + }) + }, + callback + ); }; // ============================================================================ @@ -568,7 +502,7 @@ NodeCS2.prototype.getCasketContents = function (casketId, callback) { */ NodeCS2.prototype.loadVolatileItemContents = function (volatileItemId, callback) { // Similar to getCasketContents, but for volatile items - const volatileItem = this.inventory.find((item) => item.id == volatileItemId); + const volatileItem = (this.inventory || []).find((item) => item.id == volatileItemId); if (!volatileItem) { const error = new Error(`No volatile item matching ID ${volatileItemId} was found`); if (callback) { @@ -578,78 +512,25 @@ NodeCS2.prototype.loadVolatileItemContents = function (volatileItemId, callback) return Promise.reject(error); } - // We need to load volatile item contents from the GC - this._send(Language.VolatileItemLoadContents, Protos.CMsgCasketItem, { - casket_item_id: volatileItemId, - item_item_id: volatileItemId - }); - - // Set a timeout (configurable via _volatileItemTimeout) - let timedOut = false; - let timeout; - let customizationNotification; - - if (callback) { - timeout = setTimeout(() => { - if (timedOut) { - return; - } - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - callback(new Error('Loading volatile item contents timed out')); - }, this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS); - - customizationNotification = (itemIds, notificationType) => { - if (timedOut) { - this.off('itemCustomizationNotification', customizationNotification); - return; - } - - // Check for volatile item notification (may use VolatileItemClaimReward notification type) - if (itemIds[0] != volatileItemId) { - return; - } - - clearTimeout(timeout); - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - callback( - null, - this.inventory.filter((item) => item.volatile_item_id == volatileItemId || item.id == volatileItemId) - ); - }; - - this.on('itemCustomizationNotification', customizationNotification); - } else { - return new Promise((resolve, reject) => { - timeout = setTimeout(() => { - if (timedOut) { - return; - } - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - reject(new Error('Loading volatile item contents timed out')); - }, this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS); - - customizationNotification = (itemIds, notificationType) => { - if (timedOut) { - this.off('itemCustomizationNotification', customizationNotification); - return; - } - - if (itemIds[0] != volatileItemId) { - return; - } - - clearTimeout(timeout); - timedOut = true; - this.off('itemCustomizationNotification', customizationNotification); - resolve(this.inventory.filter((item) => item.volatile_item_id == volatileItemId || item.id == volatileItemId)); - }; - - this.on('itemCustomizationNotification', customizationNotification); - }); - } + return request( + this, + { + // Share the item lane with mutations: no unique request id exists here. + key: 'item:' + volatileItemId, + event: 'itemCustomizationNotification', + timeout: this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS, + message: 'Loading volatile item contents timed out', + match: (ids, type) => + ids[0] === String(volatileItemId) && type === NodeCS2.ItemCustomizationNotification.CasketContents, + map: () => this.inventory.filter((item) => item.volatile_item_id == volatileItemId || item.id == volatileItemId), + send: () => + this._send(Language.VolatileItemLoadContents, Protos.CMsgCasketItem, { + casket_item_id: volatileItemId, + item_item_id: volatileItemId + }) + }, + callback + ); }; /** @@ -664,42 +545,19 @@ NodeCS2.prototype.claimVolatileItemReward = function (defindex, callback) { // Send as empty ByteBuffer - response comes via ItemCustomizationNotification const buffer = new ByteBuffer(4, ByteBuffer.LITTLE_ENDIAN); buffer.writeUint32(defindex); - this._send(Language.VolatileItemClaimReward, null, buffer); - - if (callback) { - // Listen for itemCustomizationNotification with VolatileItemClaimReward type - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Claiming volatile item reward timed out')); - }, this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Claiming volatile item reward timed out')); - }, this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'reward:free', + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._volatileItemTimeout || Constants.VOLATILE_ITEM_TIMEOUT_MS, + message: 'claimVolatileItemReward timed out', + match: (ids, type) => type === NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward, + send: () => this._send(Language.VolatileItemClaimReward, null, buffer) + }, + callback + ); }; /** @@ -722,38 +580,18 @@ NodeCS2.prototype.acknowledgeRentalExpiration = function (crateItemId) { * @returns {Promise|undefined} Returns a Promise if no callback is provided */ NodeCS2.prototype.requestRecurringMissionSchedule = function (callback) { - this._send(Language.RequestRecurringMissionSchedule, Protos.CMsgRequestRecurringMissionSchedule, {}); - - if (callback) { - // Listen for RecurringMissionSchema response - const timeout = setTimeout(() => { - this.removeListener('recurringMissionSchema', schemaListener); - callback(new Error('Requesting recurring mission schedule timed out')); - }, this._missionTimeout || Constants.MISSION_TIMEOUT_MS); - - const schemaListener = (schema) => { - clearTimeout(timeout); - this.removeListener('recurringMissionSchema', schemaListener); - callback(null, schema); - }; - - this.once('recurringMissionSchema', schemaListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('recurringMissionSchema', schemaListener); - reject(new Error('Requesting recurring mission schedule timed out')); - }, this._missionTimeout || Constants.MISSION_TIMEOUT_MS); - - const schemaListener = (schema) => { - clearTimeout(timeout); - this.removeListener('recurringMissionSchema', schemaListener); - resolve(schema); - }; - - this.once('recurringMissionSchema', schemaListener); - }); - } + return request( + this, + { + key: 'missionSchedule', + event: 'recurringMissionSchema', + mutation: false, + timeout: this._missionTimeout || Constants.MISSION_TIMEOUT_MS, + message: 'requestRecurringMissionSchedule timed out', + send: () => this._send(Language.RequestRecurringMissionSchedule, Protos.CMsgRequestRecurringMissionSchedule, {}) + }, + callback + ); }; // ============================================================================ @@ -776,46 +614,24 @@ NodeCS2.prototype.acknowledgeXPShopTracks = function () { * @returns {Promise|undefined} Returns a Promise if no callback is provided */ NodeCS2.prototype.redeemFreeReward = function (generationTime, redeemableBalance, items, callback) { - this._send(Language.ClientRedeemFreeReward, Protos.CMsgGCCstrike15_v2_ClientRedeemFreeReward, { - generation_time: generationTime, - redeemable_balance: redeemableBalance, - items: items || [] - }); - - if (callback) { - // Listen for itemCustomizationNotification - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Redeeming free reward timed out')); - }, this._rewardTimeout || Constants.REWARD_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Redeeming free reward timed out')); - }, this._rewardTimeout || Constants.REWARD_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'reward:free', + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._rewardTimeout || Constants.REWARD_TIMEOUT_MS, + message: 'redeemFreeReward timed out', + match: (ids, type) => type === NodeCS2.ItemCustomizationNotification.ClientRedeemFreeReward, + send: () => + this._send(Language.ClientRedeemFreeReward, Protos.CMsgGCCstrike15_v2_ClientRedeemFreeReward, { + generation_time: generationTime, + redeemable_balance: redeemableBalance, + items: items || [] + }) + }, + callback + ); }; /** @@ -842,48 +658,26 @@ NodeCS2.prototype.redeemMissionReward = function ( bidControl = undefined; } - this._send(Language.ClientRedeemMissionReward, Protos.CMsgGCCstrike15_v2_ClientRedeemMissionReward, { - campaign_id: campaignId, - redeem_id: redeemId, - redeemable_balance: redeemableBalance, - expected_cost: expectedCost, - bid_control: bidControl - }); - - if (callback) { - // Listen for itemCustomizationNotification - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Redeeming mission reward timed out')); - }, this._rewardTimeout || Constants.REWARD_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemMissionReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Redeeming mission reward timed out')); - }, this._rewardTimeout || Constants.REWARD_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ClientRedeemMissionReward) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'reward:mission', + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._rewardTimeout || Constants.REWARD_TIMEOUT_MS, + message: 'redeemMissionReward timed out', + match: (ids, type) => type === NodeCS2.ItemCustomizationNotification.ClientRedeemMissionReward, + send: () => + this._send(Language.ClientRedeemMissionReward, Protos.CMsgGCCstrike15_v2_ClientRedeemMissionReward, { + campaign_id: campaignId, + redeem_id: redeemId, + redeemable_balance: redeemableBalance, + expected_cost: expectedCost, + bid_control: bidControl + }) + }, + callback + ); }; // ============================================================================ @@ -918,7 +712,14 @@ NodeCS2.prototype.setLeaderboardSafeName = function (leaderboardSafeName) { * @param {function} callback - Optional callback. If not provided, returns a Promise. * @returns {Promise|undefined} Returns a Promise if no callback is provided */ -NodeCS2.prototype.openCrate = function (toolItemId, subjectItemId, forRental, pointsRemaining, volatileLimit, callback) { +NodeCS2.prototype.openCrate = function ( + toolItemId, + subjectItemId, + forRental, + pointsRemaining, + volatileLimit, + callback +) { // Handle optional parameters if (typeof forRental === 'function') { callback = forRental; @@ -934,54 +735,27 @@ NodeCS2.prototype.openCrate = function (toolItemId, subjectItemId, forRental, po volatileLimit = undefined; } - this._send(Language.OpenCrate, Protos.CMsgOpenCrate, { - tool_item_id: toolItemId, - subject_item_id: subjectItemId, - for_rental: forRental, - points_remaining: pointsRemaining, - volatile_limit: volatileLimit - }); - - if (callback) { - // Listen for ItemCustomizationNotification with UnlockCrate type - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Opening crate timed out')); - }, this._crateTimeout || Constants.CRATE_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.UnlockCrate) { - // Check if this is our crate - if (itemIds.indexOf(subjectItemId.toString()) !== -1 || itemIds.indexOf(subjectItemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Opening crate timed out')); - }, this._crateTimeout || Constants.CRATE_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.UnlockCrate) { - // Check if this is our crate - if (itemIds.indexOf(subjectItemId.toString()) !== -1 || itemIds.indexOf(subjectItemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + subjectItemId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._crateTimeout || Constants.CRATE_TIMEOUT_MS, + message: 'openCrate timed out', + match: (ids, type) => + type === NodeCS2.ItemCustomizationNotification.UnlockCrate && ids.includes(String(subjectItemId)), + send: () => + this._send(Language.OpenCrate, Protos.CMsgOpenCrate, { + tool_item_id: toolItemId, + subject_item_id: subjectItemId, + for_rental: forRental, + points_remaining: pointsRemaining, + volatile_limit: volatileLimit + }) + }, + callback + ); }; // ============================================================================ @@ -997,49 +771,25 @@ NodeCS2.prototype.openCrate = function (toolItemId, subjectItemId, forRental, po */ NodeCS2.prototype.extractSticker = function (itemId, stickerSlot, callback) { // Send request via ItemCustomizationNotification - this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { - item_id: [itemId], - request: NodeCS2.ItemCustomizationNotification.ExtractSticker, - extra_data: stickerSlot !== undefined ? [stickerSlot] : [] - }); - - if (callback) { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Extracting sticker timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ExtractSticker) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Extracting sticker timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ExtractSticker) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + itemId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._stickerTimeout || Constants.STICKER_TIMEOUT_MS, + message: 'extractSticker timed out', + match: (ids, type) => + type === NodeCS2.ItemCustomizationNotification.ExtractSticker && ids.includes(String(itemId)), + send: () => + this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: [itemId], + request: NodeCS2.ItemCustomizationNotification.ExtractSticker, + extra_data: stickerSlot !== undefined ? [stickerSlot] : [] + }) + }, + callback + ); }; /** @@ -1050,49 +800,25 @@ NodeCS2.prototype.extractSticker = function (itemId, stickerSlot, callback) { */ NodeCS2.prototype.encapsulateSticker = function (stickerId, callback) { // Send request via ItemCustomizationNotification - this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { - item_id: [stickerId], - request: NodeCS2.ItemCustomizationNotification.EncapsulateSticker, - extra_data: [] - }); - - if (callback) { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Encapsulating sticker timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.EncapsulateSticker) { - if (itemIds.indexOf(stickerId.toString()) !== -1 || itemIds.indexOf(stickerId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Encapsulating sticker timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.EncapsulateSticker) { - if (itemIds.indexOf(stickerId.toString()) !== -1 || itemIds.indexOf(stickerId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + stickerId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._stickerTimeout || Constants.STICKER_TIMEOUT_MS, + message: 'encapsulateSticker timed out', + match: (ids, type) => + type === NodeCS2.ItemCustomizationNotification.EncapsulateSticker && ids.includes(String(stickerId)), + send: () => + this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: [stickerId], + request: NodeCS2.ItemCustomizationNotification.EncapsulateSticker, + extra_data: [] + }) + }, + callback + ); }; // ============================================================================ @@ -1114,49 +840,24 @@ NodeCS2.prototype.applyPatch = function (itemId, patchId, patchSlot, callback) { } // Send request via ItemCustomizationNotification - this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { - item_id: [itemId, patchId], - request: NodeCS2.ItemCustomizationNotification.ApplyPatch, - extra_data: patchSlot !== undefined ? [patchSlot] : [] - }); - - if (callback) { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Applying patch timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ApplyPatch) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Applying patch timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ApplyPatch) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + itemId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._stickerTimeout || Constants.STICKER_TIMEOUT_MS, + message: 'applyPatch timed out', + match: (ids, type) => type === NodeCS2.ItemCustomizationNotification.ApplyPatch && ids.includes(String(itemId)), + send: () => + this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: [itemId, patchId], + request: NodeCS2.ItemCustomizationNotification.ApplyPatch, + extra_data: patchSlot !== undefined ? [patchSlot] : [] + }) + }, + callback + ); }; /** @@ -1168,49 +869,24 @@ NodeCS2.prototype.applyPatch = function (itemId, patchId, patchSlot, callback) { */ NodeCS2.prototype.removePatch = function (itemId, patchSlot, callback) { // Send request via ItemCustomizationNotification - this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { - item_id: [itemId], - request: NodeCS2.ItemCustomizationNotification.RemovePatch, - extra_data: patchSlot !== undefined ? [patchSlot] : [] - }); - - if (callback) { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Removing patch timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.RemovePatch) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Removing patch timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.RemovePatch) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + itemId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._stickerTimeout || Constants.STICKER_TIMEOUT_MS, + message: 'removePatch timed out', + match: (ids, type) => type === NodeCS2.ItemCustomizationNotification.RemovePatch && ids.includes(String(itemId)), + send: () => + this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: [itemId], + request: NodeCS2.ItemCustomizationNotification.RemovePatch, + extra_data: patchSlot !== undefined ? [patchSlot] : [] + }) + }, + callback + ); }; // ============================================================================ @@ -1232,49 +908,25 @@ NodeCS2.prototype.applyKeychain = function (itemId, keychainId, keychainSlot, ca } // Send request via ItemCustomizationNotification - this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { - item_id: [itemId, keychainId], - request: NodeCS2.ItemCustomizationNotification.ApplyKeychain, - extra_data: keychainSlot !== undefined ? [keychainSlot] : [] - }); - - if (callback) { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - callback(new Error('Applying keychain timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ApplyKeychain) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - callback(null, itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - } else { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.removeListener('itemCustomizationNotification', notificationListener); - reject(new Error('Applying keychain timed out')); - }, this._stickerTimeout || Constants.STICKER_TIMEOUT_MS); - - const notificationListener = (itemIds, notificationType) => { - if (notificationType == NodeCS2.ItemCustomizationNotification.ApplyKeychain) { - if (itemIds.indexOf(itemId.toString()) !== -1 || itemIds.indexOf(itemId) !== -1) { - clearTimeout(timeout); - this.removeListener('itemCustomizationNotification', notificationListener); - resolve(itemIds); - } - } - }; - - this.on('itemCustomizationNotification', notificationListener); - }); - } + return request( + this, + { + key: 'item:' + itemId, + event: 'itemCustomizationNotification', + mutation: true, + timeout: this._stickerTimeout || Constants.STICKER_TIMEOUT_MS, + message: 'applyKeychain timed out', + match: (ids, type) => + type === NodeCS2.ItemCustomizationNotification.ApplyKeychain && ids.includes(String(itemId)), + send: () => + this._send(Language.ItemCustomizationNotification, Protos.CMsgGCItemCustomizationNotification, { + item_id: [itemId, keychainId], + request: NodeCS2.ItemCustomizationNotification.ApplyKeychain, + extra_data: keychainSlot !== undefined ? [keychainSlot] : [] + }) + }, + callback + ); }; /** @@ -1330,8 +982,11 @@ NodeCS2.prototype.commendPlayer = function (accountId, commendation, matchId, to * @param {string} petItemId - Non-zero uint64 item ID as a decimal string */ NodeCS2.prototype.ackPetEvent = function (petItemId) { - if (typeof petItemId !== 'string' || !/^[1-9]\d{0,19}$/.test(petItemId) || - (petItemId.length === 20 && petItemId > '18446744073709551615')) { + if ( + typeof petItemId !== 'string' || + !/^[1-9]\d{0,19}$/.test(petItemId) || + (petItemId.length === 20 && petItemId > '18446744073709551615') + ) { throw new Error('petItemId must be a non-zero uint64 decimal string'); } this._send(Language.AckPetEvent, Protos.CMsgAckPetEvent, { pet_item_id: petItemId }); @@ -1341,3 +996,20 @@ NodeCS2.prototype._handlers = {}; require('./enums.js'); require('./handlers.js'); + +/** Reject outstanding work without retrying any sent operation. */ +NodeCS2.prototype.cancelPendingRequests = function (code = 'CANCELLED') { + for (const cancel of Array.from(this._pendingRequests || [])) cancel(code); +}; + +/** Permanently detach this instance from the transport. */ +NodeCS2.prototype.dispose = function () { + this._disposed = true; + this.cancelPendingRequests(); + clearTimeout(this._helloTimer); + this._helloTimer = null; + this.haveGCSession = false; + this._isInCSGO = false; + for (const [event, listener] of this._transportListeners || []) this._steam.removeListener(event, listener); + this._transportListeners = []; +}; diff --git a/lib/proto-decode.js b/lib/proto-decode.js index 6305414..71ee2a2 100644 --- a/lib/proto-decode.js +++ b/lib/proto-decode.js @@ -1,4 +1,5 @@ const ByteBuffer = require('bytebuffer'); +const validateWire = require('./wire-validation'); module.exports = decodeProto; @@ -7,6 +8,7 @@ function decodeProto(proto, encoded) { encoded = encoded.toBuffer(); } + validateWire(encoded, proto.name); const decoded = proto.decode(encoded); const objNoDefaults = proto.toObject(decoded, { longs: String }); const objWithDefaults = proto.toObject(decoded, { defaults: true, longs: String }); diff --git a/lib/request.js b/lib/request.js new file mode 100644 index 0000000..4eab335 --- /dev/null +++ b/lib/request.js @@ -0,0 +1,72 @@ +'use strict'; + +class RequestError extends Error { + constructor(code, message, uncertain = false) { + super(message); + this.name = 'RequestError'; + this.code = code; + this.uncertain = uncertain; + } +} + +// One active request per correlation key. Subscribe before sending, including +// transports that deliver a response synchronously from sendToGC. +function request(client, options, callback) { + client._requestQueues = client._requestQueues || new Map(); + client._pendingRequests = client._pendingRequests || new Set(); + client._uncertainKeys = client._uncertainKeys || new Set(); + const promise = new Promise((resolve, reject) => { + let done = false; + let sent = false; + let started = false; + let timer; + const queue = client._requestQueues.get(options.key) || []; + client._requestQueues.set(options.key, queue); + const finish = (error, value) => { + if (done) return; + done = true; + clearTimeout(timer); + client.removeListener(options.event, listener); + client._pendingRequests.delete(cancel); + if (error && sent) client._uncertainKeys.add(options.key); + queue.splice(queue.indexOf(start), 1); + if (!queue.length) client._requestQueues.delete(options.key); + // Do not install the next listener within the current event dispatch. + else queueMicrotask(queue[0]); + if (callback) callback(error || null, value); + else if (error) reject(error); + else resolve(value); + }; + const cancel = (code) => + finish(new RequestError(code, `Request ${code.toLowerCase()}`, sent && !!options.mutation)); + const listener = (...args) => { + if (!options.match || options.match(...args)) finish(null, options.map ? options.map(...args) : args[0]); + }; + const start = () => { + if (done || started) return; + started = true; + if (client._disposed) return cancel('DISPOSED'); + if (client._uncertainKeys.has(options.key)) return cancel('UNCERTAIN_PREVIOUS_RESULT'); + client.on(options.event, listener); + timer = setTimeout(() => { + finish(new RequestError('TIMEOUT', options.message, sent && !!options.mutation)); + if (options.onTimeout) options.onTimeout(); + }, options.timeout); + try { + sent = true; + if (options.send() === false) { + sent = false; + cancel('NOT_CONNECTED'); + } + } catch (error) { + finish(new RequestError('SEND_FAILED', error.message, !!options.mutation)); + } + }; + queue.push(start); + client._pendingRequests.add(cancel); + if (queue.length === 1) start(); + }); + return callback ? undefined : promise; +} + +module.exports = { request, RequestError }; diff --git a/lib/wire-schema.json b/lib/wire-schema.json new file mode 100644 index 0000000..5ae5b0d --- /dev/null +++ b/lib/wire-schema.json @@ -0,0 +1 @@ +{"CMsgGCCStrike15_v2_ClientLogonFatalError":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"CMsgClientWelcome":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"],"3":[2,"CMsgSOCacheSubscribed",false,".CMsgSOCacheSubscribed"],"4":[2,"CMsgSOCacheSubscriptionCheck",false,".CMsgSOCacheSubscriptionCheck"],"5":[2,"CMsgClientWelcome.Location",false,".CMsgClientWelcome.Location"],"6":[2,null,false,"bytes"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"]},"CMsgSOCacheSubscribed":{"2":[2,"CMsgSOCacheSubscribed.SubscribedType",false,".CMsgSOCacheSubscribed.SubscribedType"],"3":[1,null,false,"fixed64"],"4":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOCacheSubscribed.SubscribedType":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]},"CMsgSOIDOwner":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"]},"CMsgSOCacheSubscriptionCheck":{"2":[1,null,false,"fixed64"],"3":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgClientWelcome.Location":{"1":[5,null,false,"float"],"2":[5,null,false,"float"],"3":[2,null,false,"string"]},"CSOEconItem":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"],"12":[2,"CSOEconItemAttribute",false,".CSOEconItemAttribute"],"13":[2,"CSOEconItem",false,".CSOEconItem"],"14":[0,null,false,"bool"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint64"],"18":[2,"CSOEconItemEquipped",false,".CSOEconItemEquipped"],"19":[0,null,false,"uint32"]},"CSOEconItemAttribute":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,null,false,"bytes"]},"CSOEconItemEquipped":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve"],"3":[2,"GlobalStatistics",false,".GlobalStatistics"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"8":[2,"PlayerCommendationInfo",false,".PlayerCommendationInfo"],"9":[2,"PlayerMedalsInfo",false,".PlayerMedalsInfo"],"10":[2,"TournamentEvent",false,".TournamentEvent"],"11":[2,"TournamentTeam",false,".TournamentTeam"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[2,"TournamentEvent",false,".TournamentEvent"],"14":[0,null,false,"uint32"],"15":[2,"AccountActivity",false,".AccountActivity"],"17":[0,null,false,"int32"],"18":[0,null,false,"int32"],"19":[0,null,false,"int32"],"20":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"21":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"6":[2,null,false,"string"],"7":[2,null,false,"string"],"8":[2,"DataCenterPing",false,".DataCenterPing"],"9":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve":{"1":[0,null,true,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"],"5":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,true,"uint32"],"9":[2,"IpAddressMask",false,".IpAddressMask"],"10":[0,null,false,"uint64"],"11":[2,"TournamentEvent",false,".TournamentEvent"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[0,null,true,"uint32"],"14":[0,null,false,"uint64"],"15":[2,"CPreMatchInfoData",false,".CPreMatchInfoData"],"17":[0,null,false,"uint32"],"18":[0,null,false,"uint32"],"19":[2,"OperationalVarValue",false,".OperationalVarValue"],"20":[0,null,false,"uint32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[2,null,false,"string"]},"PlayerRankingInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint64"],"9":[2,null,false,"string"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[2,"PlayerRankingInfo.PerMapRank",false,".PlayerRankingInfo.PerMapRank"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"PlayerRankingInfo.PerMapRank":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"IpAddressMask":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"]},"TournamentEvent":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"]},"TournamentTeam":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,null,false,"string"],"5":[2,"TournamentPlayer",false,".TournamentPlayer"]},"TournamentPlayer":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,null,false,"string"],"6":[2,null,false,"string"],"7":[2,null,false,"string"]},"CPreMatchInfoData":{"1":[0,null,false,"int32"],"4":[2,"CDataGCCStrike15_v2_TournamentMatchDraft",false,".CDataGCCStrike15_v2_TournamentMatchDraft"],"5":[2,"CPreMatchInfoData.TeamStats",false,".CPreMatchInfoData.TeamStats"],"6":[0,null,true,"int32"]},"CDataGCCStrike15_v2_TournamentMatchDraft":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[0,null,false,"int32"],"9":[0,null,false,"int32"],"10":[2,"CDataGCCStrike15_v2_TournamentMatchDraft.Entry",false,".CDataGCCStrike15_v2_TournamentMatchDraft.Entry"],"11":[0,null,true,"int32"],"12":[0,null,true,"int32"],"13":[0,null,true,"int32"],"14":[0,null,true,"int32"],"15":[0,null,true,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,false,"int32"],"19":[5,null,false,"float"],"20":[5,null,false,"float"]},"CDataGCCStrike15_v2_TournamentMatchDraft.Entry":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"]},"CPreMatchInfoData.TeamStats":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"OperationalVarValue":{"1":[2,null,false,"string"],"2":[0,null,false,"int32"],"3":[5,null,false,"float"],"4":[2,null,false,"bytes"]},"DataCenterPing":{"1":[5,null,false,"fixed32"],"2":[0,null,false,"sint32"]},"GlobalStatistics":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[2,"DetailedSearchStatistic",false,".DetailedSearchStatistic"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"DetailedSearchStatistic":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerCommendationInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerMedalsInfo":{"7":[0,null,true,"uint32"],"8":[0,null,false,"uint32"]},"AccountActivity":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"]},"CMsgConnectionStatus":{"1":[0,null,false,".GCConnectionStatus"],"2":[0,null,false,"uint32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchList":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[2,"CDataGCCStrike15_v2_MatchInfo",false,".CDataGCCStrike15_v2_MatchInfo"],"5":[2,"TournamentTeam",false,".TournamentTeam"],"6":[2,"CDataGCCStrike15_v2_TournamentInfo",false,".CDataGCCStrike15_v2_TournamentInfo"]},"CDataGCCStrike15_v2_MatchInfo":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[2,"WatchableMatchInfo",false,".WatchableMatchInfo"],"4":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"]},"WatchableMatchInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[2,null,false,"bytes"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,false,"uint32"],"9":[2,null,false,"string"],"10":[2,null,false,"string"],"11":[0,null,false,"uint64"],"12":[0,null,false,"uint64"],"13":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats":{"1":[0,null,false,"uint64"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"3":[2,null,false,"string"],"4":[0,null,false,"int32"],"5":[0,null,true,"int32"],"6":[0,null,true,"int32"],"7":[0,null,true,"int32"],"8":[0,null,true,"int32"],"9":[0,null,true,"int32"],"10":[0,null,false,"int32"],"11":[0,null,false,"int32"],"12":[0,null,true,"int32"],"13":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm"],"14":[0,null,false,"int32"],"15":[0,null,false,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,true,"int32"],"19":[0,null,true,"int32"],"20":[0,null,true,"int32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[0,null,true,"int32"],"26":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo"],"27":[0,null,false,"bool"],"28":[0,null,true,"int32"],"29":[0,null,true,"int32"],"30":[0,null,true,"int32"],"31":[0,null,false,"uint32"],"32":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo":{"1":[0,null,false,"uint32"]},"CDataGCCStrike15_v2_TournamentInfo":{"1":[2,"CDataGCCStrike15_v2_TournamentSection",false,".CDataGCCStrike15_v2_TournamentSection"],"2":[2,"TournamentEvent",false,".TournamentEvent"],"3":[2,"TournamentTeam",false,".TournamentTeam"]},"CDataGCCStrike15_v2_TournamentSection":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,"CDataGCCStrike15_v2_TournamentGroup",false,".CDataGCCStrike15_v2_TournamentGroup"]},"CDataGCCStrike15_v2_TournamentGroup":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,"CDataGCCStrike15_v2_TournamentGroupTeam",false,".CDataGCCStrike15_v2_TournamentGroupTeam"],"6":[0,null,true,"int32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,"CDataGCCStrike15_v2_TournamentGroup.Picks",false,".CDataGCCStrike15_v2_TournamentGroup.Picks"]},"CDataGCCStrike15_v2_TournamentGroupTeam":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"bool"]},"CDataGCCStrike15_v2_TournamentGroup.Picks":{"1":[0,null,true,"int32"]},"CMsgGCCStrike15_v2_PlayersProfile":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientHello"]},"CMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockResponse":{"1":[2,"CEconItemPreviewDataBlock",false,".CEconItemPreviewDataBlock"]},"CEconItemPreviewDataBlock":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[2,null,false,"string"],"12":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"],"17":[0,null,false,"uint32"],"18":[0,null,false,"int32"],"19":[0,null,false,"uint32"],"20":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"21":[0,null,false,"uint32"],"22":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[2,null,false,"bytes"]},"CEconItemPreviewDataBlock.Sticker":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[5,null,false,"float"],"4":[5,null,false,"float"],"5":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[5,null,false,"float"],"8":[5,null,false,"float"],"9":[5,null,false,"float"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_GC2ClientNotifyXPShop":{"1":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"2":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"CSOAccountXpShop":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint32"]},"CMsgRecurringMissionSchema":{"1":[2,"CMsgRecurringMissionSchema.MissionTemplateList",false,".CMsgRecurringMissionSchema.MissionTemplateList"]},"CMsgRecurringMissionSchema.MissionTemplateList":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"]},"CMsgGCCStrike15_v2_PremierSeasonSummary":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek"],"4":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"]},"CMsgGCItemCustomizationNotification":{"1":[0,null,true,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint64"]},"CMsgSOSingleObject":{"2":[0,null,false,"int32"],"3":[2,null,false,"bytes"],"4":[1,null,false,"fixed64"],"5":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects":{"2":[2,"CMsgSOMultipleObjects.SingleObject",false,".CMsgSOMultipleObjects.SingleObject"],"3":[1,null,false,"fixed64"],"6":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects.SingleObject":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]}} diff --git a/lib/wire-validation.js b/lib/wire-validation.js new file mode 100644 index 0000000..f293482 --- /dev/null +++ b/lib/wire-validation.js @@ -0,0 +1,61 @@ +'use strict'; +const schemas = require('./wire-schema.json'); + +// protobufjs can accept a length-delimited string truncated at the buffer end. +// Validate boundaries before decoding, including every known nested message. +module.exports = function validateWire(bytes, name) { + let offset = 0; + const fail = () => { + throw new Error('Malformed protobuf wire data'); + }; + function varint(end) { + let value = 0n; + for (let shift = 0; shift < 70; shift += 7) { + if (offset >= end) return fail(); + const byte = bytes[offset++]; + if (shift === 63 && byte > 1) return fail(); + value |= BigInt(byte & 127) << BigInt(shift); + if (!(byte & 128)) return value; + } + return fail(); + } + function scan(end, schema, depth, group) { + if (depth > 64) return fail(); + while (offset < end) { + const tag = varint(end); + if (tag > 0xffffffffn || tag < 8n) return fail(); + const field = Number(tag >> 3n), + wire = Number(tag & 7n); + const known = schema && schema[field]; + if (wire === 4) { + if (field !== group) return fail(); + return; + } + if (known && wire !== known[0] && !(known[2] && wire === 2)) return fail(); + if (wire === 0) { + const value = varint(end); + if (known && ['uint32', 'sint32'].includes(known[3]) && value > 0xffffffffn) return fail(); + } else if (wire === 1 || wire === 5) { + offset += wire === 1 ? 8 : 4; + if (offset > end) return fail(); + } else if (wire === 2) { + const length = varint(end); + if (length > BigInt(end - offset)) return fail(); + const limit = offset + Number(length); + if (known && known[1]) scan(limit, schemas[known[1]], depth + 1); + else if (known && known[2]) { + if (known[0] === 0) { + while (offset < limit) { + const value = varint(limit); + if (['uint32', 'sint32'].includes(known[3]) && value > 0xffffffffn) return fail(); + } + } else if (Number(length) % (known[0] === 1 ? 8 : 4)) return fail(); + } + offset = limit; + } else if (wire === 3) scan(end, null, depth + 1, field); + else return fail(); + } + if (group !== undefined) return fail(); + } + scan(bytes.length, schemas[name], 0); +}; diff --git a/package.json b/package.json index 72f2e1b..f3dd188 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-cs2", - "version": "2.5.0", + "version": "2.6.0", "description": "Modern CS2/CS:GO Game Coordinator integration with latest GameTracking-CS2 protobuf definitions. Includes support for highlight_reel, wrapped_sticker, variations, Promise-based API, crate opening, sticker/patch/keychain operations, and all modern CS2 fields.", "keywords": [ "cs2", @@ -47,16 +47,24 @@ "prettier": "^3.9.6", "protobufjs-cli": "^1.3.3", "steam-user": "^5.3.0", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "typescript": "^5.9.3", + "@types/node": "^24.0.0", + "@types/steam-user": "^5.1.1" }, "scripts": { "download-protos": "node scripts/download-protos.js", - "generate-protos": "node scripts/generate-protos.js", + "generate-protos": "node scripts/generate-protos.js && npm run generate-wire-schema && npm run generate-response-types", "update-protos": "npm run download-protos && npm run generate-protos", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write .", - "test": "vitest run" + "test": "vitest run", + "test:types": "tsc -p test/types/tsconfig.json", + "test:live": "node scripts/live-test.js", + "test:consumer": "node scripts/verify-consumers.js", + "generate-response-types": "node scripts/generate-response-types.js", + "generate-wire-schema": "node scripts/generate-wire-schema.js" }, "engines": { "node": ">=14.0.0" @@ -74,6 +82,9 @@ "EXAMPLES.md", "DETAILED_DOCUMENTATION.md", "CHANGELOG.md", - "LICENSE" + "LICENSE", + "FEATURE_SUPPORT.md", + "RELEASE_NOTES.md", + "scripts/live-test.js" ] } diff --git a/scripts/generate-inspect-corpus.py b/scripts/generate-inspect-corpus.py new file mode 100644 index 0000000..8578843 --- /dev/null +++ b/scripts/generate-inspect-corpus.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Independent synthetic wire corpus. No library/protobuf runtime imports. + +Schema: protobufs/cstrike15_gcmessages.proto CEconItemPreviewDataBlock, +Valve GameTracking-CS2 snapshot recorded in each repository. These are NOT +captured Steam replays. struct implements IEEE754; zlib implements CRC32. +Run from either repository; copy fixtures/inspect-corpus.json to the other. +""" +import json +import pathlib +import random +import struct +import zlib + +ROOT = pathlib.Path(__file__).resolve().parent.parent + +def varint(value): + value &= (1 << 64) - 1 + output = bytearray() + while value > 127: + output.append((value & 127) | 128) + value >>= 7 + return bytes(output + bytes([value])) + +def scalar(field, value): + return varint(field << 3) + varint(value) + +def blob(field, value): + return varint((field << 3) | 2) + varint(len(value)) + value + +def f32(value): + return struct.unpack('I', checksum) + return bytes([mask] + [b ^ mask for b in raw[1:]]).hex().upper() + +FIELDS = {'accountid':1,'itemid':2,'defindex':3,'paintindex':4,'rarity':5,'quality':6,'paintwear':7,'paintseed':8,'killeaterscoretype':9,'killeatervalue':10,'inventory':13,'origin':14,'questid':15,'dropreason':16,'musicindex':17,'entindex':18,'petindex':19,'style':21,'upgrade_level':23,'pet_food_expiration_date':24} +ATTACHMENTS = {'stickers':12,'keychains':20,'variations':22} +SFIELDS = {'slot':1,'sticker_id':2,'wear':3,'scale':4,'rotation':5,'tint_id':6,'offset_x':7,'offset_y':8,'offset_z':9,'pattern':10,'highlight_reel':11,'wrapped_sticker':12} +UNKNOWN = scalar(101, 0xffffffffffffffff) + blob(102, bytes.fromhex('00ff80fe')) + varint(103 << 3 | 5) + bytes.fromhex('78563412') + varint(104 << 3 | 1) + bytes.fromhex('8877665544332211') +NESTED = blob(77, bytes.fromhex('00ffaa')) + scalar(78, 9007199254740993) + +def encode(item, unknown=False): + parts = [] + for key, field in FIELDS.items(): + if key in item: + value = item[key] + if key == 'paintwear': + value = struct.unpack(' + path.join( + __dirname, + '../protobufs', + path.basename(file) === 'descriptor.proto' ? 'google/protobuf/descriptor.proto' : path.basename(file) + ); +root.loadSync(['cstrike15_gcmessages.proto', 'gcsdk_gcmessages.proto'], { keepCase: true }).resolveAll(); +const roots = [ + 'CMsgConnectionStatus', + 'CMsgGCCStrike15_v2_MatchmakingGC2ClientHello', + 'CMsgGCCStrike15_v2_MatchList', + 'CMsgGCCStrike15_v2_GC2ClientNotifyXPShop', + 'CMsgRecurringMissionSchema', + 'CMsgGCCStrike15_v2_PremierSeasonSummary', + 'CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats' +]; +const seen = new Map(); +const name = (type) => type.fullName.slice(1).replace(/\./g, '_'); +function visit(type) { + if (seen.has(name(type))) return; + seen.set(name(type), type); + for (const field of type.fieldsArray) if (field.resolvedType instanceof pb.Type) visit(field.resolvedType); +} +roots.forEach((n) => visit(root.lookupType(n))); +let out = + '// Generated by scripts/generate-response-types.js from the checked-in protocol snapshot.\n// Matches lib/proto-decode: uint64 strings, absent scalars/messages null, repeated fields arrays.\n'; +for (const [n, type] of seen) { + out += `export interface ${n} {\n`; + for (const f of type.fieldsArray) { + const t = + f.resolvedType instanceof pb.Type + ? name(f.resolvedType) + : f.resolvedType instanceof pb.Enum + ? 'number' + : /64$/.test(f.type) + ? 'string' + : f.type === 'bytes' + ? 'Uint8Array' + : f.type === 'bool' + ? 'boolean' + : f.type === 'string' + ? 'string' + : 'number'; + out += `\t${f.name}: ${t}${f.repeated ? '[]' : ' | null'};\n`; + } + out += '}\n'; +} +fs.writeFileSync(path.join(__dirname, '../types/responses.d.ts'), out); diff --git a/scripts/generate-wire-schema.js b/scripts/generate-wire-schema.js new file mode 100644 index 0000000..45e11b0 --- /dev/null +++ b/scripts/generate-wire-schema.js @@ -0,0 +1,42 @@ +'use strict'; +const fs = require('fs'); +const path = require('path'); +const pb = require('protobufjs'); +const root = new pb.Root(); +root.resolvePath = (_, file) => + path.join( + __dirname, + '../protobufs', + path.basename(file) === 'descriptor.proto' ? 'google/protobuf/descriptor.proto' : path.basename(file) + ); +root + .loadSync( + ['cstrike15_gcmessages.proto', 'gcsdk_gcmessages.proto', 'base_gcmessages.proto', 'econ_gcmessages.proto'], + { keepCase: true } + ) + .resolveAll(); +const schema = {}; +function visit(type) { + const name = type.fullName.slice(1); + if (schema[name]) return name; + const fields = (schema[name] = {}); + for (const field of type.fieldsArray) { + const nested = field.resolvedType instanceof pb.Type ? visit(field.resolvedType) : null; + const wire = + nested || ['string', 'bytes'].includes(field.type) + ? 2 + : ['double', 'fixed64', 'sfixed64'].includes(field.type) + ? 1 + : ['float', 'fixed32', 'sfixed32'].includes(field.type) + ? 5 + : 0; + fields[field.id] = [wire, nested, !!field.repeated && wire !== 2, field.type]; + } + return name; +} +const source = ['../handlers.js', '../lib/inspect-link.js'] + .map((file) => fs.readFileSync(path.join(__dirname, file), 'utf8')) + .join('\n') + .replace(/\/\*[\s\S]*?\*\//g, ''); +for (const match of source.matchAll(/decodeProto\(Protos\.(\w+)/g)) visit(root.lookupType(match[1])); +fs.writeFileSync(path.join(__dirname, '../lib/wire-schema.json'), JSON.stringify(schema) + '\n'); diff --git a/scripts/live-test.js b/scripts/live-test.js new file mode 100644 index 0000000..4b17ff9 --- /dev/null +++ b/scripts/live-test.js @@ -0,0 +1,195 @@ +'use strict'; + +// An opt-in harness, never a background task. Reports contain no account or item IDs. +const fs = require('fs'); +const { isDeepStrictEqual } = require('util'); +const NodeCS2 = require('../'); +const SNAPSHOT = '3fc98e763328f7d1627405b389d1b6b69c5b0e38'; +const READS = new Set(['profile', 'inspect', 'missions', 'casket']); +const MUTATIONS = { + applyPatch: [0, 1], + removePatch: [0], + extractSticker: [0], + encapsulateSticker: [0], + applyKeychain: [0, 1], + openCrate: [0, 1] +}; +function failure(code, message) { + const error = new Error(message); + error.code = code; + return error; +} +function selectCase(config, name) { + if (name === 'profile') return { name, method: 'profile', args: [], mutation: false }; + const entry = (config.cases || {})[name]; + if (!entry || !Array.isArray(entry.args)) throw failure('PREREQUISITE', 'Selected case is not configured'); + if (READS.has(entry.method)) return { ...entry, name, mutation: false }; + const indexes = Object.prototype.hasOwnProperty.call(MUTATIONS, entry.method) && MUTATIONS[entry.method]; + if (!indexes) throw failure('PREREQUISITE', 'This method has no supported live assertion'); + const designated = new Set(config.testItemIds || []); + if (!indexes.every((i) => typeof entry.args[i] === 'string' && designated.has(entry.args[i]))) { + throw failure('PREREQUISITE', 'Every consumed or changed item must be designated'); + } + if ( + !Array.isArray(entry.expectedInventory) || + !entry.expectedInventory.length || + !entry.expectedInventory.every( + (check) => + typeof check.id === 'string' && + designated.has(check.id) && + typeof check.present === 'boolean' && + (!check.present || (check.properties && Object.keys(check.properties).length)) + ) + ) { + throw failure('PREREQUISITE', 'An explicit observable inventory postcondition is required'); + } + return { ...entry, name, mutation: true, itemIndexes: indexes }; +} +function inventoryMatches(inventory, expected) { + return expected.every((check) => { + const item = inventory.find((candidate) => candidate.id === check.id); + if (!check.present) return !item; + return !!item && Object.entries(check.properties).every(([key, value]) => isDeepStrictEqual(item[key], value)); + }); +} +function classify(error, mutationSent) { + if (mutationSent && error.code !== 'ASSERTION') return 'uncertain'; + if (error.code === 'PREREQUISITE') return 'unavailable-prerequisite'; + if (error.code === 'ASSERTION') return 'assertion-failure'; + return 'steam-or-gc-outage'; +} +function waitEvent(emitter, event, timeout) { + let cancel; + const promise = new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timer); + emitter.removeListener(event, success); + emitter.removeListener('error', fail); + }; + const success = (...args) => { + cleanup(); + resolve(args); + }; + const fail = (error) => { + cleanup(); + reject(error); + }; + const timer = setTimeout(() => fail(failure('TIMEOUT', 'Steam/GC response unavailable')), timeout); + cancel = cleanup; + emitter.once(event, success); + emitter.once('error', fail); + }); + // A transport may emit an error and then throw before the caller can await. + promise.catch(() => {}); + return { promise, cancel }; +} +async function runCase(cs, steam, selected, markSent) { + const args = selected.args; + if (selected.method === 'profile') { + const value = await cs.requestPlayersProfile(args[0] || steam.steamID); + if (!value.account_id) throw failure('ASSERTION', 'Profile response missing account'); + return; + } + if (selected.method === 'inspect') { + if (typeof args[0] !== 'string') throw failure('PREREQUISITE', 'Inspect link required'); + const item = await cs.inspectItem(args[0]); + if (!item.itemid) throw failure('ASSERTION', 'Inspect response missing item'); + return; + } + if (selected.method === 'missions') { + await cs.requestRecurringMissionSchedule(); + return; + } + if (selected.method === 'casket') { + if (!(cs.inventory || []).some((item) => item.id === args[0])) + throw failure('PREREQUISITE', 'Casket is unavailable'); + await cs.getCasketContents(args[0]); + return; + } + if (!selected.itemIndexes.every((i) => (cs.inventory || []).some((item) => item.id === args[i]))) { + throw failure('PREREQUISITE', 'Designated test items are unavailable'); + } + if (inventoryMatches(cs.inventory || [], selected.expectedInventory)) + throw failure('PREREQUISITE', 'Postcondition already holds; cannot verify a change'); + markSent(); + // Exactly one invocation. Never retry even after timeout, disconnect, or send failure. + await cs[selected.method](...args); + const deadline = Date.now() + 10000; + while (!inventoryMatches(cs.inventory || [], selected.expectedInventory)) { + if (Date.now() >= deadline) + throw failure('UNCERTAIN', 'Notification received but inventory postcondition not observed'); + await new Promise((resolve) => setTimeout(resolve, 100)); + } +} +async function main(argv = process.argv.slice(2), env = process.env, dependencies = {}) { + const waiters = []; + let steam, + cs, + mutationSent = false; + let name = 'profile'; + const report = { date: new Date().toISOString(), protocolSnapshot: SNAPSHOT, case: name }; + try { + if (env.CS2_LIVE !== '1') throw failure('PREREQUISITE', 'Set CS2_LIVE=1 to enable authenticated tests'); + if (argv.length && (argv.length !== 2 || argv[0] !== '--case' || !argv[1])) + throw failure('PREREQUISITE', 'Select exactly one --case'); + if (argv.length) name = argv[1]; + report.case = name; + let config; + try { + config = env.CS2_LIVE_CONFIG ? JSON.parse(fs.readFileSync(env.CS2_LIVE_CONFIG, 'utf8')) : {}; + } catch (error) { + throw failure('PREREQUISITE', 'Live configuration must be readable JSON'); + } + if (!config || typeof config !== 'object' || Array.isArray(config)) + throw failure('PREREQUISITE', 'Live configuration must be an object'); + const selected = selectCase(config, name); + report.method = selected.method; + if (!env.STEAM_REFRESH_TOKEN && !(env.STEAM_ACCOUNT_NAME && env.STEAM_PASSWORD)) + throw failure('PREREQUISITE', 'Steam credentials are unavailable'); + const SteamUser = dependencies.SteamUser || require('steam-user'); + steam = new SteamUser({ dataDirectory: null }); + cs = new NodeCS2(steam); + // Keep error events handled after individual waiters settle. + steam.on('error', () => {}); + cs.on('error', () => {}); + const loggedOn = waitEvent(steam, 'loggedOn', 60000); + waiters.push(loggedOn.cancel); + steam.logOn( + env.STEAM_REFRESH_TOKEN + ? { refreshToken: env.STEAM_REFRESH_TOKEN } + : { + accountName: env.STEAM_ACCOUNT_NAME, + password: env.STEAM_PASSWORD, + twoFactorCode: env.STEAM_TWO_FACTOR_CODE + } + ); + try { + await loggedOn.promise; + } catch (error) { + if ([5, 63, 65, 85, 88].includes(error.eresult)) + throw failure('PREREQUISITE', 'Steam authentication requires attention'); + throw error; + } + const connected = waitEvent(cs, 'connectedToGC', 60000); + waiters.push(connected.cancel); + steam.gamesPlayed([730]); + await connected.promise; + await runCase(cs, steam, selected, () => { + mutationSent = true; + }); + report.outcome = 'passed'; + } catch (error) { + report.outcome = classify(error, mutationSent); + report.code = typeof error.code === 'string' ? error.code : 'UNAVAILABLE'; + process.exitCode = { 'unavailable-prerequisite': 2, 'steam-or-gc-outage': 3, 'assertion-failure': 1, uncertain: 4 }[ + report.outcome + ]; + } finally { + for (const cancel of waiters) cancel(); + if (cs) cs.dispose(); + if (steam) steam.logOff(); + } + console.log(JSON.stringify(report)); +} +module.exports = { selectCase, inventoryMatches, classify, runCase, main, waitEvent }; +if (require.main === module) main(); diff --git a/scripts/verify-consumers.js b/scripts/verify-consumers.js new file mode 100644 index 0000000..8fc5222 --- /dev/null +++ b/scripts/verify-consumers.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +/* Validate the packed npm artifact, not repository-relative imports. */ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); +const root = path.resolve(__dirname, '..'); +const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'node-cs2-consumers-')); +function run(command, args, cwd = temporary) { + const result = cp.spawnSync(command, args, { cwd, encoding: 'utf8', timeout: 300000 }); + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} failed\n${result.stdout}\n${result.stderr}`); + return result.stdout.trim(); +} +try { + const [packed] = JSON.parse(run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], root)); + assert(packed.files.some(file => file.path === 'types/index.d.ts')); + assert(packed.files.some(file => file.path === 'protobufs/generated/_load.js')); + assert(!packed.files.some(file => /^(?:test|node_modules)\//.test(file.path))); + fs.writeFileSync(path.join(temporary, 'package.json'), JSON.stringify({ private: true })); + // Set NODE_CS2_TEST_RUNTIMES=current for a fast smoke check; default tests the + // advertised minimum and supported major release lines with actual binaries. + const versions = (process.env.NODE_CS2_TEST_RUNTIMES || '14.21.3,16.20.2,18.20.8,20.19.0,22.12.0,24.0.0').split(','); + const peers = ['4.2.0', '4.29.3', '5.3.0']; + const runtimeDeps = versions.filter(version => version !== 'current').map(version => `runtime-${version.split('.')[0]}@npm:node@${version}`); + run('npm', ['install', '--no-audit', '--no-fund', path.join(temporary, packed.filename), ...peers.map(version => `steam-${version.replace(/\./g, '-')}@npm:steam-user@${version}`), ...runtimeDeps]); + fs.copyFileSync(path.join(root, 'fixtures/inspect-corpus.json'), path.join(temporary, 'corpus.json')); + fs.writeFileSync(path.join(temporary, 'consumer.cjs'), ` +const assert = require('assert').strict; +const NodeCS2 = require('node-cs2'); +const Steam = require(process.argv[2]); +const corpus = require('./corpus.json'); +const steam = new Steam({ dataDirectory: null }); +const cs2 = new NodeCS2(steam); +(async () => { + for (const fixture of corpus.valid) { + const item = await cs2.inspectItem('steam://rungame/730/0/+csgo_econ_action_preview%20' + fixture.token); + assert.equal(item.itemid, fixture.expected.itemid); + assert.equal(item.paintwear, fixture.expected.paintwear); + } + assert.equal(typeof cs2.inspectItem, 'function'); + if (cs2.dispose) cs2.dispose(); + console.log(process.version + ' / ' + process.argv[2] + ': 56 independent fixtures passed'); +})().catch(error => { console.error(error); process.exitCode = 1; }); +`); + for (const version of versions) { + const binary = version === 'current' ? process.execPath : path.join(temporary, 'node_modules', `runtime-${version.split('.')[0]}`, 'bin', 'node'); + for (const peer of peers) console.log(run(binary, ['consumer.cjs', `steam-${peer.replace(/\./g, '-')}`])); + } + console.log(`Validated packed ${packed.filename}; Node runtime and steam-user matrix complete.`); +} finally { + if (process.env.KEEP_CONSUMER_PROJECT) console.log(`Consumer project retained: ${temporary}`); + else fs.rmSync(temporary, { recursive: true, force: true }); +} diff --git a/test/corpus.test.mjs b/test/corpus.test.mjs new file mode 100644 index 0000000..2fa94be --- /dev/null +++ b/test/corpus.test.mjs @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { describe, it, expect, vi } from 'vitest'; +const require = createRequire(import.meta.url); +const NodeCS2 = require('../index.js'); +const Language = require('../language.js'); +const corpus = JSON.parse(readFileSync(new URL('../fixtures/inspect-corpus.json', import.meta.url), 'utf8')); +const link = token => `steam://rungame/730/0/+csgo_econ_action_preview%20${token}`; +function instance() { const cs2 = Object.create(NodeCS2.prototype); cs2._send = vi.fn(); return cs2; } +function expected(item) { return { ...item, ...(item.blobdata === undefined ? {} : { blobdata: Buffer.from(item.blobdata, 'hex') }) }; } +// The same bytes and expectations are committed in cs2-inspect-lib. A schema +// encoder is deliberately not used to construct any input in this suite. +describe('shared independent inspect corpus', () => { + it.each(corpus.valid)('$name decodes through public embedded API and real GC handler', async fixture => { + const cs2 = instance(); + const embedded = await cs2.inspectItem(link(fixture.token)); + expect(embedded).toMatchObject(expected(fixture.expected)); + expect(cs2._send).not.toHaveBeenCalled(); + const event = vi.fn(); cs2.on('inspectItemInfo', event); + const bytes = Buffer.from(fixture.protobufHex, 'hex'); + // Independent field-1 length-delimited GC envelope, no generated encode(). + const length = []; let remaining = bytes.length; + do { length.push((remaining & 127) | (remaining > 127 ? 128 : 0)); remaining >>>= 7; } while (remaining); + cs2._handlers[Language.Client2GCEconPreviewDataBlockResponse].call(cs2, Buffer.concat([Buffer.from([10, ...length]), bytes])); + expect(event).toHaveBeenCalledWith(embedded); + if (fixture.expected.customnames?.length) expect(embedded.customname).toBe(fixture.expected.customnames.at(-1)); + }); + it.each(corpus.malformed)('rejects $name with a valid independently computed checksum', fixture => { + const cs2 = instance(); + expect(() => cs2.inspectItem(link(fixture.token))).toThrow(); + expect(cs2._send).not.toHaveBeenCalled(); + }); + it('rejects deterministic checksum corruption for every generated item', () => { + for (const fixture of corpus.valid) { + const corrupted = fixture.token.slice(0, -2) + (fixture.token.slice(-2) === '00' ? '01' : '00'); + expect(() => instance().inspectItem(link(corrupted))).toThrow(/checksum/i); + } + }); +}); diff --git a/test/helpers/fake-steam.mjs b/test/helpers/fake-steam.mjs new file mode 100644 index 0000000..eaf65f0 --- /dev/null +++ b/test/helpers/fake-steam.mjs @@ -0,0 +1,39 @@ +import { EventEmitter } from 'node:events'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +export const CS2 = require('../../'); +export const Language = require('../../language'); +// Minimal steam-user contract; no library encoder or handler is replaced. +export class FakeSteam extends EventEmitter { + packageName = 'steam-user'; + packageVersion = '5.3.0'; + steamID = '76561198000000001'; + sent = []; + sendToGC(appid, type, headers, payload) { + this.sent.push({ appid, type, headers, payload: Buffer.from(payload) }); + this.onSend?.(type, payload); + } + receive(type, bytes) { + this.emit('receivedFromGC', 730, type, Buffer.from(bytes)); + } +} +// Independent wire encoder, deliberately does not use generated protobuf code. +export function varint(value) { + let n = BigInt(value); + const bytes = []; + do { + let b = Number(n & 127n); + n >>= 7n; + if (n) b |= 128; + bytes.push(b); + } while (n); + return bytes; +} +export const uint = (field, value) => [...varint(field * 8), ...varint(value)]; +export const bytes = (field, value) => [...varint(field * 8 + 2), ...varint(value.length), ...value]; +export const fixed64 = (field, value) => [ + ...varint(field * 8 + 1), + ...Array.from({ length: 8 }, (_, i) => Number((BigInt(value) >> BigInt(i * 8)) & 255n)) +]; +export const inspect = (id) => bytes(1, [...uint(2, id), ...uint(3, 7), ...uint(7, 1040187392)]); +export const notification = (id, type) => [...uint(1, id), ...uint(2, type)]; diff --git a/test/lifecycle.test.mjs b/test/lifecycle.test.mjs new file mode 100644 index 0000000..bcec7e6 --- /dev/null +++ b/test/lifecycle.test.mjs @@ -0,0 +1,259 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { CS2, FakeSteam, Language as L, inspect, notification, bytes, uint, fixed64 } from './helpers/fake-steam.mjs'; +let steam, cs; +beforeEach(() => { + vi.useFakeTimers(); + steam = new FakeSteam(); + cs = new CS2(steam); + cs._inspectTimeout = 50; + cs._stickerTimeout = 50; +}); +afterEach(() => { + cs.dispose(); + vi.useRealTimers(); +}); +it('correlates concurrent inspect replies out of order, with exactly-once callback/Promise completion', async () => { + const cb = vi.fn(); + const a = cs.inspectItem('M1A11D1'); + cs.inspectItem('M1A22D1', cb); + expect(steam.sent[0].payload.toString('hex')).toBe('0800100b18012001'); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(22)); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(22)); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(11)); + expect((await a).itemid).toBe('11'); + expect(cb).toHaveBeenCalledTimes(1); + expect(cs.listenerCount('inspectItemInfo#11')).toBe(0); + expect(vi.getTimerCount()).toBe(0); +}); +it('handles a synchronous transport response', async () => { + steam.onSend = () => steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(11)); + expect((await cs.inspectItem('M1A11D1')).itemid).toBe('11'); +}); +it('times out, ignores late replies, and blocks unsafe correlation reuse', async () => { + const a = cs.inspectItem('M1A11D1'); + const failure = expect(a).rejects.toMatchObject({ code: 'TIMEOUT', uncertain: false }); + await vi.advanceTimersByTimeAsync(50); + await failure; + steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(11)); + await expect(cs.inspectItem('M1A11D1')).rejects.toMatchObject({ code: 'UNCERTAIN_PREVIOUS_RESULT' }); + expect(cs.listenerCount('inspectItemInfo#11')).toBe(0); +}); +it.each(['extractSticker', 'applyPatch', 'removePatch', 'applyKeychain', 'encapsulateSticker'])( + '%s checks item and notification type and serializes same-item work', + async (method) => { + const types = { + extractSticker: 'ExtractSticker', + applyPatch: 'ApplyPatch', + removePatch: 'RemovePatch', + applyKeychain: 'ApplyKeychain', + encapsulateSticker: 'EncapsulateSticker' + }; + const type = CS2.ItemCustomizationNotification[types[method]]; + const a = cs[method]('11', ...(method === 'encapsulateSticker' ? [] : [2])); + const b = cs[method]('11', ...(method === 'encapsulateSticker' ? [] : [3])); + expect(steam.sent).toHaveLength(1); + steam.receive(L.ItemCustomizationNotification, notification(22, type)); + steam.receive(L.ItemCustomizationNotification, notification(11, type + 1)); + expect(steam.sent).toHaveLength(1); + steam.receive(L.ItemCustomizationNotification, notification(11, type)); + steam.receive(L.ItemCustomizationNotification, notification(11, type)); + await a; + await vi.runAllTicks(); + expect(steam.sent).toHaveLength(2); + steam.receive(L.ItemCustomizationNotification, notification(11, type)); + await b; + expect(vi.getTimerCount()).toBe(0); + } +); +it('serializes rewards globally when responses do not identify a request', async () => { + const a = cs.redeemFreeReward(1, 1, [1]); + const b = cs.claimVolatileItemReward(2); + expect(steam.sent).toHaveLength(1); + steam.receive( + L.ItemCustomizationNotification, + notification(88, CS2.ItemCustomizationNotification.ClientRedeemFreeReward) + ); + await a; + await vi.runAllTicks(); + expect(steam.sent).toHaveLength(2); + steam.receive( + L.ItemCustomizationNotification, + notification(99, CS2.ItemCustomizationNotification.ClientRedeemFreeReward) + ); + expect(await b).toEqual(['99']); +}); +it('cancels active and queued mutations on disconnect without retrying', async () => { + const a = cs.applyPatch('11', '22'); + const b = cs.applyPatch('11', '33'); + const results = Promise.allSettled([a, b]); + steam.emit('disconnected'); + expect((await results).map((r) => r.reason.code)).toEqual(['DISCONNECTED', 'DISCONNECTED']); + expect(vi.getTimerCount()).toBe(0); + expect(cs.listenerCount('itemCustomizationNotification')).toBe(0); + steam.emit('appLaunched', 730); + expect(vi.getTimerCount()).toBe(1); + steam.emit('appQuit', 730); + expect(vi.getTimerCount()).toBe(0); + cs.dispose(); + for (const event of ['receivedFromGC', 'appLaunched', 'appQuit', 'disconnected', 'error']) + expect(steam.listenerCount(event)).toBe(0); +}); +it('cleans profile callback and Promise listeners on timeout', async () => { + cs._profileTimeout = 50; + const cb = vi.fn(); + cs.requestPlayersProfile('76561198000000001', cb); + const a = cs.requestPlayersProfile('76561198000000002'); + const failed = expect(a).rejects.toMatchObject({ code: 'TIMEOUT' }); + await vi.advanceTimersByTimeAsync(50); + await failed; + expect(cb.mock.calls[0][0]).toBeInstanceOf(Error); + expect(cs.eventNames().filter((n) => String(n).startsWith('playersProfile#'))).toEqual([]); +}); +it('reports malformed protobuf and skips unknown fields while preserving uint64/zero', async () => { + const errors = vi.fn(); + cs.on('error', errors); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, [10, 127, 128]); + expect(errors).toHaveBeenCalledOnce(); + const a = cs.inspectItem('M1A18446744073709551615D1'); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, [...inspect('18446744073709551615'), ...uint(99, 1)]); + expect((await a).itemid).toBe('18446744073709551615'); + expect(cs._mapStickerLikeItem({ wear: 0, offset_x: 0 }).wear).toBe(0); +}); +it('inventory messages are idempotent and reject reordered cache versions including tombstones', () => { + steam.receive(L.ClientWelcome, []); + const changes = vi.fn(); + cs.on('itemChanged', changes); + const acquired = vi.fn(); + cs.on('itemAcquired', acquired); + const removed = vi.fn(); + cs.on('itemRemoved', removed); + const so = (v, position = 0) => [ + ...uint(2, 1), + ...bytes(3, [...uint(1, 11), ...uint(3, position)]), + ...fixed64(4, v) + ]; + steam.receive(L.SO_Create, so(1)); + steam.receive(L.SO_Create, so(1)); + expect(cs.inventory).toHaveLength(1); + expect(acquired).toHaveBeenCalledOnce(); + steam.receive(L.SO_Update, so(3, 2)); + steam.receive(L.SO_Update, so(2, 1)); + expect(changes).toHaveBeenCalledOnce(); + steam.receive(L.SO_Destroy, so(4)); + steam.receive(L.SO_Destroy, so(4)); + steam.receive(L.SO_Create, so(3)); + expect(cs.inventory).toHaveLength(0); + expect(removed).toHaveBeenCalledOnce(); +}); +it.each(['4.2.0', '4.29.3', '5.3.0'])('accepts the %s steam transport contract', (version) => { + steam.packageVersion = version; + const other = new CS2(steam); + other.dispose(); +}); +it('volatile contents ignore unrelated customizations and subscribe before the send', async () => { + cs.inventory = [{ id: '11' }]; + cs._volatileItemTimeout = 50; + steam.onSend = () => { + steam.receive(L.ItemCustomizationNotification, notification(11, CS2.ItemCustomizationNotification.ApplyPatch)); + steam.receive(L.ItemCustomizationNotification, notification(11, CS2.ItemCustomizationNotification.CasketContents)); + }; + expect(await cs.loadVolatileItemContents('11')).toEqual([{ id: '11' }]); + expect(cs.listenerCount('itemCustomizationNotification')).toBe(0); +}); +it('delivers all profiles in a multi-profile response', async () => { + const a = cs.requestPlayersProfile('76561197960265729'); + const b = cs.requestPlayersProfile('76561197960265730'); + steam.receive(L.PlayersProfile, [...bytes(2, uint(1, 2)), ...bytes(2, uint(1, 1))]); + expect((await a).account_id).toBe(1); + expect((await b).account_id).toBe(2); +}); +it('preserves absent paintwear and rejects nested truncated strings', () => { + const items = vi.fn(), + errors = vi.fn(); + cs.on('inspectItemInfo', items); + cs.on('error', errors); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, bytes(1, uint(2, 11))); + expect(items.mock.calls[0][0].paintwear).toBeNull(); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, bytes(1, [...uint(2, 11), 90, 5, 97, 98])); + expect(errors).toHaveBeenCalledOnce(); + expect(items).toHaveBeenCalledOnce(); + steam.receive(L.CraftResponse, [1]); + expect(errors).toHaveBeenCalledTimes(2); +}); +it('manual cancellation rejects queued and active work exactly once and preserves other listeners', async () => { + const external = vi.fn(); + cs.on('inspectItemInfo#11', external); + const a = cs.inspectItem('M1A11D1'), + b = cs.inspectItem('M1A11D1'); + const results = Promise.allSettled([a, b]); + cs.cancelPendingRequests(); + cs.cancelPendingRequests(); + expect((await results).map((r) => r.reason.code)).toEqual(['CANCELLED', 'CANCELLED']); + expect(steam.sent).toHaveLength(1); + expect(cs.listenerCount('inspectItemInfo#11')).toBe(1); + expect(vi.getTimerCount()).toBe(0); +}); +it('mutation timeout is uncertain and never sends queued work', async () => { + const a = cs.applyPatch('11', '22'), + b = cs.applyKeychain('11', '33'); + const results = Promise.allSettled([a, b]); + await vi.advanceTimersByTimeAsync(50); + expect((await results).map((r) => ({ code: r.reason.code, uncertain: r.reason.uncertain }))).toEqual([ + { code: 'TIMEOUT', uncertain: true }, + { code: 'UNCERTAIN_PREVIOUS_RESULT', uncertain: false } + ]); + expect(steam.sent).toHaveLength(1); +}); +it('does not poison unsent requests and suppresses transport sends after disposal', async () => { + steam.steamID = null; + await expect(cs.inspectItem('M1A11D1')).rejects.toMatchObject({ code: 'NOT_CONNECTED' }); + steam.steamID = '76561198000000001'; + steam.onSend = () => steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(11)); + expect((await cs.inspectItem('M1A11D1')).itemid).toBe('11'); + cs.dispose(); + cs.ackPetEvent('11'); + expect(steam.sent).toHaveLength(1); + await expect(cs.inspectItem('M1A12D1')).rejects.toMatchObject({ code: 'DISPOSED' }); +}); +it('callback and Promise receive identical customization results through dispatch', async () => { + const callback = vi.fn(); + cs.removePatch('11', 0, callback); + const result = cs.removePatch('22', 0); + steam.receive(L.ItemCustomizationNotification, notification(22, 1089)); + steam.receive(L.ItemCustomizationNotification, notification(11, 1089)); + expect(callback).toHaveBeenCalledWith(null, ['11']); + expect(await result).toEqual(['22']); +}); +it('SO multiple versions, duplicate creates, and malformed attributes preserve inventory state', () => { + steam.receive(L.ClientWelcome, []); + const item = (position) => [...uint(1, 11), ...uint(3, position)]; + const so = (version, position) => [...uint(2, 1), ...bytes(3, item(position)), ...fixed64(4, version)]; + steam.receive(L.SO_Create, so(1, 1)); + steam.receive(L.SO_Create, so(2, 2)); + expect(cs.inventory[0].inventory).toBe(2); + steam.receive(L.SO_UpdateMultiple, [...bytes(2, [...uint(1, 1), ...bytes(2, item(4))]), ...fixed64(3, 4)]); + steam.receive(L.SO_Update, so(3, 3)); + expect(cs.inventory[0].inventory).toBe(4); +}); +it('keeps explicit market owners, canonicalizes request keys, and validates uint64 input', async () => { + const result = cs.inspectItem('M76561198000000001A00011D00000'); + expect(steam.sent[0].payload).toEqual( + Buffer.from([...uint(1, 0), ...uint(2, 11), ...uint(3, 0), ...uint(4, '76561198000000001')]) + ); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, inspect(11)); + expect((await result).itemid).toBe('11'); + for (const value of ['18446744073709551616', '-1', '1.5', 9007199254740992, NaN]) { + expect(() => cs.inspectItem('1', value, '1')).toThrow('uint64'); + } + expect(() => cs.inspectItem('S1A11D1')).toThrow('SteamID'); + expect(steam.sent).toHaveLength(1); +}); +it('rejects incoming known uint32 overflow and ignores missing connection status', () => { + const errors = vi.fn(); + cs.on('error', errors); + cs.haveGCSession = true; + steam.receive(L.ClientConnectionStatus, []); + expect(cs.haveGCSession).toBe(true); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, bytes(1, [...uint(2, 11), ...uint(3, '4294967296')])); + expect(errors).toHaveBeenCalledOnce(); +}); diff --git a/test/live-runner.test.mjs b/test/live-runner.test.mjs new file mode 100644 index 0000000..5b53876 --- /dev/null +++ b/test/live-runner.test.mjs @@ -0,0 +1,76 @@ +import { it, expect, vi } from 'vitest'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const { selectCase, inventoryMatches, classify, runCase } = require('../scripts/live-test'); +it('defaults to a read and rejects unconfigured or undesignated mutations', () => { + expect(selectCase({}, 'profile')).toMatchObject({ mutation: false, method: 'profile' }); + expect(() => selectCase({}, 'all')).toThrow(); + expect(() => selectCase({ cases: { patch: { method: 'applyPatch', args: ['1', '2'] } } }, 'patch')).toThrow( + 'designated' + ); + expect(() => + selectCase({ testItemIds: ['1', '2'], cases: { patch: { method: 'applyPatch', args: ['1', '2'] } } }, 'patch') + ).toThrow('postcondition'); +}); +it('requires a concrete changed inventory postcondition', () => { + const config = { + testItemIds: ['1', '2'], + cases: { patch: { method: 'applyPatch', args: ['1', '2'], expectedInventory: [{ id: '2', present: false }] } } + }; + expect(selectCase(config, 'patch').mutation).toBe(true); + expect(inventoryMatches([{ id: '2' }], config.cases.patch.expectedInventory)).toBe(false); + expect(inventoryMatches([{ id: '1' }], config.cases.patch.expectedInventory)).toBe(true); +}); +it('never retries an uncertain mutation and distinguishes outcomes', async () => { + const failure = Object.assign(new Error('timeout'), { code: 'TIMEOUT' }); + const cs = { inventory: [{ id: '1' }, { id: '2' }], applyPatch: vi.fn().mockRejectedValue(failure) }; + const selected = selectCase( + { + testItemIds: ['1', '2'], + cases: { patch: { method: 'applyPatch', args: ['1', '2'], expectedInventory: [{ id: '2', present: false }] } } + }, + 'patch' + ); + const sent = vi.fn(); + await expect(runCase(cs, {}, selected, sent)).rejects.toBe(failure); + expect(cs.applyPatch).toHaveBeenCalledTimes(1); + expect(sent).toHaveBeenCalledOnce(); + expect(classify(failure, true)).toBe('uncertain'); + expect(classify(failure, false)).toBe('steam-or-gc-outage'); + expect(classify({ code: 'ASSERTION' }, false)).toBe('assertion-failure'); + expect(classify({ code: 'PREREQUISITE' }, false)).toBe('unavailable-prerequisite'); +}); +it('cleans authentication waiters after a synchronous login failure', async () => { + const { FakeSteam } = await import('./helpers/fake-steam.mjs'); + class ThrowingSteam extends FakeSteam { + logOn() { + throw new Error('offline'); + } + logOff() {} + } + const { main } = require('../scripts/live-test'); + vi.useFakeTimers(); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const previous = process.exitCode; + try { + await main([], { CS2_LIVE: '1', STEAM_REFRESH_TOKEN: 'test-token' }, { SteamUser: ThrowingSteam }); + expect(vi.getTimerCount()).toBe(0); + expect(JSON.parse(log.mock.calls[0][0]).outcome).toBe('steam-or-gc-outage'); + } finally { + process.exitCode = previous; + log.mockRestore(); + vi.useRealTimers(); + } +}); +it('reports missing configuration files as unavailable prerequisites', async () => { + const { main } = require('../scripts/live-test'); + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + const previous = process.exitCode; + try { + await main([], { CS2_LIVE: '1', CS2_LIVE_CONFIG: '/nonexistent/node-cs2-live.json' }); + expect(JSON.parse(log.mock.calls[0][0]).outcome).toBe('unavailable-prerequisite'); + } finally { + process.exitCode = previous; + log.mockRestore(); + } +}); diff --git a/test/payloads.test.mjs b/test/payloads.test.mjs new file mode 100644 index 0000000..4b3c775 --- /dev/null +++ b/test/payloads.test.mjs @@ -0,0 +1,109 @@ +import { afterEach, beforeEach, expect, it } from 'vitest'; +import { CS2, FakeSteam, Language as L, uint, bytes } from './helpers/fake-steam.mjs'; +let steam, cs; +beforeEach(() => { + steam = new FakeSteam(); + cs = new CS2(steam); + cs.inventory = [{ id: '11', casket_contained_item_count: 1 }]; +}); +afterEach(() => cs.dispose()); +// Field numbers and encodings come from the checked-in .proto schema. No +// protobuf encoder or decoder participates in the expected bytes. +const notification = (ids, type, slot) => [ + ...ids.flatMap((id) => uint(1, id)), + ...uint(2, type), + ...(slot === undefined ? [] : uint(3, slot)) +]; +it.each([ + ['requestPlayersProfile', ['76561197960265729'], 'ClientRequestPlayersProfile', [...uint(3, 1), ...uint(4, 32)]], + ['requestRecurringMissionSchedule', [], 'RequestRecurringMissionSchedule', []], + ['getCasketContents', ['11'], 'CasketItemLoadContents', [...uint(1, 11), ...uint(2, 11)]], + ['loadVolatileItemContents', ['11'], 'VolatileItemLoadContents', [...uint(1, 11), ...uint(2, 11)]], + [ + 'redeemFreeReward', + [1, 0, ['18446744073709551615']], + 'ClientRedeemFreeReward', + [...uint(1, 1), ...uint(2, 0), ...uint(3, '18446744073709551615')] + ], + [ + 'redeemMissionReward', + [1, 2, 0, 0, 0], + 'ClientRedeemMissionReward', + [...uint(1, 1), ...uint(2, 2), ...uint(3, 0), ...uint(4, 0), ...uint(5, 0)] + ], + ['claimVolatileItemReward', [0xffffffff], 'VolatileItemClaimReward', [255, 255, 255, 255]], + [ + 'openCrate', + ['11', '22', false, 0, 0], + 'OpenCrate', + [...uint(1, 11), ...uint(2, 22), ...uint(3, 0), ...uint(4, 0), ...uint(5, 0)] + ], + ['extractSticker', ['11', 0], 'ItemCustomizationNotification', notification([11], 1054, 0)], + ['encapsulateSticker', ['11'], 'ItemCustomizationNotification', notification([11], 1055)], + ['applyPatch', ['11', '22', 0], 'ItemCustomizationNotification', notification([11, 22], 1090, 0)], + ['removePatch', ['11', 0], 'ItemCustomizationNotification', notification([11], 1089, 0)], + ['applyKeychain', ['11', '22', 0], 'ItemCustomizationNotification', notification([11, 22], 1091, 0)] +])('%s emits independent schema bytes', (method, args, type, payload) => { + cs[method](...args, () => {}); + expect(steam.sent).toHaveLength(1); + expect(steam.sent[0]).toMatchObject({ appid: 730, type: L[type], payload: Buffer.from(payload) }); +}); +it.each([ + ['addToCasket', ['11', '22'], 'CasketItemAdd', [...uint(1, 11), ...uint(2, 22)]], + ['removeFromCasket', ['11', '22'], 'CasketItemExtract', [...uint(1, 11), ...uint(2, 22)]], + ['removeKeychain', ['11'], 'ApplySticker', [...uint(1, '17293822569102704705'), ...uint(2, 11)]], + ['acknowledgeRentalExpiration', ['11'], 'AcknowledgeRentalExpiration', uint(1, 11)], + ['acknowledgeXPShopTracks', [], 'Client2GcAckXPShopTracks', []], + ['setLeaderboardSafeName', ['🎮'], 'SetPlayerLeaderboardSafeName', bytes(1, Buffer.from('🎮'))], + ['ackPetEvent', ['18446744073709551615'], 'AckPetEvent', uint(1, '18446744073709551615')], + [ + 'requestGame', + [{ matchId: '11', outcomeId: '22', token: 0 }], + 'MatchListRequestFullGameInfo', + [...uint(1, 11), ...uint(2, 22), ...uint(3, 0)] + ], + ['requestLiveGames', [], 'MatchListRequestCurrentLiveGames', []], + ['requestRecentGames', ['76561197960265729'], 'MatchListRequestRecentUserGames', uint(1, 1)], + ['requestLiveGameForUser', ['76561197960265729'], 'MatchListRequestLiveGameForUser', uint(1, 1)], + [ + 'commendPlayer', + [1, { cmd_friendly: true }, '18446744073709551615', 0], + 'ClientCommendPlayer', + [ + ...uint(1, 1), + ...uint(8, '18446744073709551615'), + ...bytes(9, [...uint(1, 1), ...uint(2, 0), ...uint(4, 0)]), + ...uint(10, 0) + ] + ], + ['deleteItem', ['11'], 'Delete', [11, 0, 0, 0, 0, 0, 0, 0]], + [ + 'nameItem', + ['11', '22', '名'], + 'NameItem', + [11, 0, 0, 0, 0, 0, 0, 0, 22, 0, 0, 0, 0, 0, 0, 0, 0, ...Buffer.from('名'), 0] + ], + ['craft', [['11'], 0], 'Craft', [0, 0, 1, 0, 11, 0, 0, 0, 0, 0, 0, 0]] +])('%s emits independent fire-and-forget bytes', (method, args, type, payload) => { + cs[method](...args); + expect(steam.sent[0]).toMatchObject({ appid: 730, type: L[type], payload: Buffer.from(payload) }); +}); +it.each([ + ['MatchmakingGC2ClientHello', 'accountData', uint(1, 1), { account_id: 1 }], + ['GC2ClientNotifyXPShop', 'xpShopNotification', uint(3, 0), { current_xp: 0 }], + [ + 'RecurringMissionSchema', + 'recurringMissionSchema', + bytes(1, uint(1, 2)), + { missions: [{ period: 2, mission_templates: [] }] } + ], + ['MatchmakingGC2ClientSearchStats', 'matchmakingSearchStats', uint(4, 0), { num_found_nearby: 0 }], + ['MatchList', 'matchList', bytes(4, uint(1, '18446744073709551615')), [{ matchid: '18446744073709551615' }]] +])('%s decodes independent incoming bytes', (type, event, payload, expected) => { + let value; + cs.on(event, (data) => { + value = data; + }); + steam.receive(L[type], payload); + expect(value).toMatchObject(expected); +}); diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 0000000..c391be0 --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "noEmit": true, + "target": "ES2020", + "module": "commonjs", + "skipLibCheck": false + }, + "files": ["usage.ts"] +} diff --git a/test/types/usage.ts b/test/types/usage.ts new file mode 100644 index 0000000..ab26364 --- /dev/null +++ b/test/types/usage.ts @@ -0,0 +1,32 @@ +import CS2 = require('../..'); +import SteamUser = require('steam-user'); +const cs = new CS2(new SteamUser()); +const inspection: Promise = cs.inspectItem('M1A2D3'); +const patch: Promise = cs.applyPatch('1', '2'); +const chain: Promise = cs.applyKeychain('1', '2'); +const crate: Promise = cs.openCrate('1', '2'); +const reward: Promise = cs.redeemMissionReward(1, 2, 3, 4); +const schedule: Promise = cs.requestRecurringMissionSchedule(); +cs.requestPlayersProfile('76561198000000001', (profile) => { + if (!(profile instanceof Error)) { + const rank: number | null | undefined = profile.ranking?.rank_id; + } +}); +cs.on('accountData', (data) => { + const account: number | null = data.account_id; +}); +cs.on('matchList', (matches, data) => { + const same: typeof matches = data.matches; +}); +cs.on('connectionStatus', (status, data) => { + const position: number | null = data.queue_position; +}); +cs.requestRecurringMissionSchedule((error, schema) => { + const typed: CS2.RecurringMissionSchema | undefined = schema; +}); +const error = new CS2.RequestError('TIMEOUT', 'Timed out', true); +const uncertain: boolean = error.uncertain; +cs.cancelPendingRequests(); +cs.dispose(); +// @ts-expect-error Item IDs must not lose precision through number coercion. +cs.applyPatch(9007199254740993, '2'); diff --git a/types/index.d.ts b/types/index.d.ts index c7ab3ff..9c5e662 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events'; import SteamUser = require('steam-user'); import SteamID = require('steamid'); +import * as Responses from './responses'; declare namespace NodeCS2 { // ─── Enums ────────────────────────────────────────────────────────────────── @@ -100,13 +101,13 @@ declare namespace NodeCS2 { } interface ItemInfo { - accountid: string | null; + accountid: number | null; itemid: string; defindex: number; paintindex: number; rarity: number; quality: number; - paintwear: number; + paintwear: number | null; paintseed: number; killeaterscoretype: number | null; killeatervalue: number | null; @@ -128,25 +129,30 @@ declare namespace NodeCS2 { entindex: number | null; } - interface PlayerProfile { + /** Decoded protocol objects: absent scalar fields are null, repeated fields are arrays. */ + type AccountData = Responses.CMsgGCCStrike15_v2_MatchmakingGC2ClientHello; + interface PlayerProfile extends AccountData { account_id: number; - ongoingmatch: unknown; - global_stats: unknown; - penalty_seconds: number | null; - penalty_reason: number | null; - vac_banned: number | null; - ranking: unknown; - commendation: unknown; - medals: unknown; - my_current_event: unknown; - my_current_event_teams: unknown[]; - my_current_team: unknown; - my_current_event_stages: unknown[]; - survey_vote: number | null; - activity: unknown; - player_level: number | null; - player_cur_xp: number | null; - player_xp_bonus_flags: number | null; + } + type ConnectionStatus = Responses.CMsgConnectionStatus; + type MatchList = Responses.CMsgGCCStrike15_v2_MatchList; + type XPShopNotification = Responses.CMsgGCCStrike15_v2_GC2ClientNotifyXPShop; + type RecurringMissionSchema = Responses.CMsgRecurringMissionSchema; + type PremierSeasonSummary = Responses.CMsgGCCStrike15_v2_PremierSeasonSummary; + type MatchmakingSearchStats = Responses.CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats; + type RequestErrorCode = + | 'TIMEOUT' + | 'CANCELLED' + | 'DISCONNECTED' + | 'DISPOSED' + | 'NOT_CONNECTED' + | 'SEND_FAILED' + | 'UNCERTAIN_PREVIOUS_RESULT'; + class RequestError extends Error { + constructor(code: string, message: string, uncertain?: boolean); + code: RequestErrorCode | (string & {}); + /** A sent mutation may have completed despite the error. Never retry automatically. */ + uncertain: boolean; } interface ShareCodeDetails { @@ -160,9 +166,9 @@ declare namespace NodeCS2 { interface Events { connectedToGC: () => void; disconnectedFromGC: (reason: GCConnectionStatus) => void; - connectionStatus: (status: GCConnectionStatus, data: unknown) => void; - accountData: (data: unknown) => void; - matchList: (matches: unknown[], data: unknown) => void; + connectionStatus: (status: GCConnectionStatus, data: ConnectionStatus) => void; + accountData: (data: AccountData) => void; + matchList: (matches: MatchList['matches'], data: MatchList) => void; inspectItemInfo: (item: ItemInfo) => void; inspectItemTimedOut: (assetid: string) => void; itemAcquired: (item: EconItem) => void; @@ -171,10 +177,10 @@ declare namespace NodeCS2 { itemCustomizationNotification: (itemIds: string[], notificationType: number) => void; playersProfile: (profile: PlayerProfile) => void; craftingComplete: (recipe: number, itemIds: string[]) => void; - xpShopNotification: (data: unknown) => void; - recurringMissionSchema: (schema: unknown) => void; - premierSeasonSummary: (data: unknown) => void; - matchmakingSearchStats: (data: unknown) => void; + xpShopNotification: (data: XPShopNotification) => void; + recurringMissionSchema: (schema: RecurringMissionSchema) => void; + premierSeasonSummary: (data: PremierSeasonSummary) => void; + matchmakingSearchStats: (data: MatchmakingSearchStats) => void; debug: (message: string) => void; error: (error: Error) => void; } @@ -186,7 +192,7 @@ declare class NodeCS2 extends EventEmitter { // ─── Properties ───────────────────────────────────────────────────────────── haveGCSession: boolean; inventory: NodeCS2.EconItem[]; - accountData: unknown; + accountData: NodeCS2.AccountData | undefined; // Configurable timeouts _inspectTimeout: number; @@ -200,6 +206,10 @@ declare class NodeCS2 extends EventEmitter { // ─── Connection ───────────────────────────────────────────────────────────── helloGC(): void; + /** Cancel active and queued requests; sent correlation lanes remain quarantined. */ + cancelPendingRequests(code?: NodeCS2.RequestErrorCode): void; + /** Cancel pending requests and permanently detach transport listeners. */ + dispose(): void; // ─── Match Data ───────────────────────────────────────────────────────────── requestGame(shareCodeOrDetails: string | NodeCS2.ShareCodeDetails): void; @@ -240,8 +250,10 @@ declare class NodeCS2 extends EventEmitter { acknowledgeRentalExpiration(crateItemId: string): void; // ─── Recurring Missions ───────────────────────────────────────────────────── - requestRecurringMissionSchedule(callback: (error: Error | null, schema?: unknown) => void): void; - requestRecurringMissionSchedule(): Promise; + requestRecurringMissionSchedule( + callback: (error: Error | null, schema?: NodeCS2.RecurringMissionSchema) => void + ): void; + requestRecurringMissionSchedule(): Promise; // ─── XP Shop & Rewards ────────────────────────────────────────────────────── acknowledgeXPShopTracks(): void; @@ -257,8 +269,8 @@ declare class NodeCS2 extends EventEmitter { redeemId: number, redeemableBalance: number, expectedCost: number, - bidControl?: number, - callback?: (error: Error | null, itemIds?: string[]) => void + bidControl: number | undefined, + callback: (error: Error | null, itemIds?: string[]) => void ): void; redeemMissionReward( campaignId: number, @@ -282,10 +294,10 @@ declare class NodeCS2 extends EventEmitter { openCrate( toolItemId: string, subjectItemId: string, - forRental?: boolean, - pointsRemaining?: number, - volatileLimit?: number, - callback?: (error: Error | null, itemIds?: string[]) => void + forRental: boolean | undefined, + pointsRemaining: number | undefined, + volatileLimit: number | undefined, + callback: (error: Error | null, itemIds?: string[]) => void ): void; openCrate( toolItemId: string, @@ -327,8 +339,8 @@ declare class NodeCS2 extends EventEmitter { applyPatch( itemId: string, patchId: string, - patchSlot?: number, - callback?: (error: Error | null, itemIds?: string[]) => void + patchSlot: number | undefined, + callback: (error: Error | null, itemIds?: string[]) => void ): void; applyPatch(itemId: string, patchId: string, callback: (error: Error | null, itemIds?: string[]) => void): void; applyPatch(itemId: string, patchId: string, patchSlot?: number): Promise; @@ -339,8 +351,8 @@ declare class NodeCS2 extends EventEmitter { applyKeychain( itemId: string, keychainId: string, - keychainSlot?: number, - callback?: (error: Error | null, itemIds?: string[]) => void + keychainSlot: number | undefined, + callback: (error: Error | null, itemIds?: string[]) => void ): void; applyKeychain(itemId: string, keychainId: string, callback: (error: Error | null, itemIds?: string[]) => void): void; applyKeychain(itemId: string, keychainId: string, keychainSlot?: number): Promise; diff --git a/types/responses.d.ts b/types/responses.d.ts new file mode 100644 index 0000000..19e52f1 --- /dev/null +++ b/types/responses.d.ts @@ -0,0 +1,363 @@ +// Generated by scripts/generate-response-types.js from the checked-in protocol snapshot. +// Matches lib/proto-decode: uint64 strings, absent scalars/messages null, repeated fields arrays. +export interface CMsgConnectionStatus { + status: number | null; + client_session_need: number | null; + queue_position: number | null; + queue_size: number | null; + wait_seconds: number | null; + estimated_wait_seconds_remaining: number | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingGC2ClientHello { + account_id: number | null; + ongoingmatch: CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve | null; + global_stats: GlobalStatistics | null; + penalty_seconds: number | null; + penalty_reason: number | null; + vac_banned: number | null; + ranking: PlayerRankingInfo | null; + commendation: PlayerCommendationInfo | null; + medals: PlayerMedalsInfo | null; + my_current_event: TournamentEvent | null; + my_current_event_teams: TournamentTeam[]; + my_current_team: TournamentTeam | null; + my_current_event_stages: TournamentEvent[]; + survey_vote: number | null; + activity: AccountActivity | null; + player_level: number | null; + player_cur_xp: number | null; + player_xp_bonus_flags: number | null; + rankings: PlayerRankingInfo[]; + owcaseid: string | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve { + serverid: string | null; + direct_udp_ip: number | null; + direct_udp_port: number | null; + reservationid: string | null; + reservation: CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve | null; + map: string | null; + server_address: string | null; + gs_ping: DataCenterPing | null; + gs_location_id: number | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve { + account_ids: number[]; + game_type: number | null; + match_id: string | null; + server_version: number | null; + flags: number | null; + rankings: PlayerRankingInfo[]; + encryption_key: string | null; + encryption_key_pub: string | null; + party_ids: number[]; + whitelist: IpAddressMask[]; + tv_master_steamid: string | null; + tournament_event: TournamentEvent | null; + tournament_teams: TournamentTeam[]; + tournament_casters_account_ids: number[]; + tv_relay_steamid: string | null; + pre_match_data: CPreMatchInfoData | null; + tv_control: number | null; + op_var_values: OperationalVarValue[]; + socache_control: number | null; + teammate_colors: number[]; + match_id_additional: number | null; + clan_tags: string[]; +} +export interface PlayerRankingInfo { + account_id: number | null; + rank_id: number | null; + wins: number | null; + rank_change: number | null; + rank_type_id: number | null; + tv_control: number | null; + rank_window_stats: string | null; + leaderboard_name: string | null; + rank_if_win: number | null; + rank_if_lose: number | null; + rank_if_tie: number | null; + per_map_rank: PlayerRankingInfo_PerMapRank[]; + leaderboard_name_status: number | null; + highest_rank: number | null; + rank_expiry: number | null; +} +export interface PlayerRankingInfo_PerMapRank { + map_id: number | null; + rank_id: number | null; + wins: number | null; +} +export interface IpAddressMask { + a: number | null; + b: number | null; + c: number | null; + d: number | null; + bits: number | null; + token: number | null; +} +export interface TournamentEvent { + event_id: number | null; + event_tag: string | null; + event_name: string | null; + event_time_start: number | null; + event_time_end: number | null; + event_public: number | null; + event_stage_id: number | null; + event_stage_name: string | null; + active_section_id: number | null; +} +export interface TournamentTeam { + team_id: number | null; + team_tag: string | null; + team_flag: string | null; + team_name: string | null; + players: TournamentPlayer[]; +} +export interface TournamentPlayer { + account_id: number | null; + player_nick: string | null; + player_name: string | null; + player_dob: number | null; + player_flag: string | null; + player_location: string | null; + player_desc: string | null; +} +export interface CPreMatchInfoData { + predictions_pct: number | null; + draft: CDataGCCStrike15_v2_TournamentMatchDraft | null; + stats: CPreMatchInfoData_TeamStats[]; + wins: number[]; +} +export interface CDataGCCStrike15_v2_TournamentMatchDraft { + event_id: number | null; + event_stage_id: number | null; + team_id_0: number | null; + team_id_1: number | null; + maps_count: number | null; + maps_current: number | null; + team_id_start: number | null; + team_id_veto1: number | null; + team_id_pickn: number | null; + drafts: CDataGCCStrike15_v2_TournamentMatchDraft_Entry[]; + vote_mapid_0: number[]; + vote_mapid_1: number[]; + vote_mapid_2: number[]; + vote_mapid_3: number[]; + vote_mapid_4: number[]; + vote_mapid_5: number[]; + vote_starting_side: number[]; + vote_phase: number | null; + vote_phase_start: number | null; + vote_phase_length: number | null; +} +export interface CDataGCCStrike15_v2_TournamentMatchDraft_Entry { + mapid: number | null; + team_id_ct: number | null; +} +export interface CPreMatchInfoData_TeamStats { + match_info_idxtxt: number | null; + match_info_txt: string | null; + match_info_teams: string[]; +} +export interface OperationalVarValue { + name: string | null; + ivalue: number | null; + fvalue: number | null; + svalue: Uint8Array | null; +} +export interface DataCenterPing { + data_center_id: number | null; + ping: number | null; +} +export interface GlobalStatistics { + players_online: number | null; + servers_online: number | null; + players_searching: number | null; + servers_available: number | null; + ongoing_matches: number | null; + search_time_avg: number | null; + search_statistics: DetailedSearchStatistic[]; + main_post_url: string | null; + required_appid_version: number | null; + pricesheet_version: number | null; + twitch_streams_version: number | null; + active_tournament_eventid: number | null; + active_survey_id: number | null; + rtime32_cur: number | null; + required_appid_version2: number | null; +} +export interface DetailedSearchStatistic { + game_type: number | null; + search_time_avg: number | null; + players_searching: number | null; +} +export interface PlayerCommendationInfo { + cmd_friendly: number | null; + cmd_teaching: number | null; + cmd_leader: number | null; +} +export interface PlayerMedalsInfo { + display_items_defidx: number[]; + featured_display_item_defidx: number | null; +} +export interface AccountActivity { + activity: number | null; + mode: number | null; + map: number | null; + matchid: string | null; +} +export interface CMsgGCCStrike15_v2_MatchList { + msgrequestid: number | null; + accountid: number | null; + servertime: number | null; + matches: CDataGCCStrike15_v2_MatchInfo[]; + streams: TournamentTeam[]; + tournamentinfo: CDataGCCStrike15_v2_TournamentInfo | null; +} +export interface CDataGCCStrike15_v2_MatchInfo { + matchid: string | null; + matchtime: number | null; + watchablematchinfo: WatchableMatchInfo | null; + roundstats_legacy: CMsgGCCStrike15_v2_MatchmakingServerRoundStats | null; + roundstatsall: CMsgGCCStrike15_v2_MatchmakingServerRoundStats[]; +} +export interface WatchableMatchInfo { + server_ip: number | null; + tv_port: number | null; + tv_spectators: number | null; + tv_time: number | null; + tv_watch_password: Uint8Array | null; + cl_decryptdata_key: string | null; + cl_decryptdata_key_pub: string | null; + game_type: number | null; + game_mapgroup: string | null; + game_map: string | null; + server_id: string | null; + match_id: string | null; + reservation_id: string | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingServerRoundStats { + reservationid: string | null; + reservation: CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve | null; + map: string | null; + round: number | null; + kills: number[]; + assists: number[]; + deaths: number[]; + scores: number[]; + pings: number[]; + round_result: number | null; + match_result: number | null; + team_scores: number[]; + confirm: CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm | null; + reservation_stage: number | null; + match_duration: number | null; + enemy_kills: number[]; + enemy_headshots: number[]; + enemy_3ks: number[]; + enemy_4ks: number[]; + enemy_5ks: number[]; + mvps: number[]; + spectators_count: number | null; + spectators_count_tv: number | null; + spectators_count_lnk: number | null; + enemy_kills_agg: number[]; + drop_info: CMsgGCCStrike15_v2_MatchmakingServerRoundStats_DropInfo | null; + b_switched_teams: boolean | null; + enemy_2ks: number[]; + player_spawned: number[]; + team_spawn_count: number[]; + max_rounds: number | null; + map_id: number | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm { + token: number | null; + stamp: number | null; + exchange: string | null; + retry: number | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingServerRoundStats_DropInfo { + account_mvp: number | null; +} +export interface CDataGCCStrike15_v2_TournamentInfo { + sections: CDataGCCStrike15_v2_TournamentSection[]; + tournament_event: TournamentEvent | null; + tournament_teams: TournamentTeam[]; +} +export interface CDataGCCStrike15_v2_TournamentSection { + sectionid: number | null; + name: string | null; + desc: string | null; + groups: CDataGCCStrike15_v2_TournamentGroup[]; +} +export interface CDataGCCStrike15_v2_TournamentGroup { + groupid: number | null; + name: string | null; + desc: string | null; + picks__deprecated: number | null; + teams: CDataGCCStrike15_v2_TournamentGroupTeam[]; + stage_ids: number[]; + picklockuntiltime: number | null; + pickableteams: number | null; + points_per_pick: number | null; + picks: CDataGCCStrike15_v2_TournamentGroup_Picks[]; +} +export interface CDataGCCStrike15_v2_TournamentGroupTeam { + team_id: number | null; + score: number | null; + correctpick: boolean | null; +} +export interface CDataGCCStrike15_v2_TournamentGroup_Picks { + pickids: number[]; +} +export interface CMsgGCCStrike15_v2_GC2ClientNotifyXPShop { + prematch: CSOAccountXpShop | null; + postmatch: CSOAccountXpShop | null; + current_xp: number | null; + current_level: number | null; +} +export interface CSOAccountXpShop { + generation_time: number | null; + redeemable_balance: number | null; + xp_tracks: number[]; +} +export interface CMsgRecurringMissionSchema { + missions: CMsgRecurringMissionSchema_MissionTemplateList[]; +} +export interface CMsgRecurringMissionSchema_MissionTemplateList { + period: number | null; + mission_templates: Uint8Array[]; +} +export interface CMsgGCCStrike15_v2_PremierSeasonSummary { + account_id: number | null; + season_id: number | null; + data_per_week: CMsgGCCStrike15_v2_PremierSeasonSummary_DataPerWeek[]; + data_per_map: CMsgGCCStrike15_v2_PremierSeasonSummary_DataPerMap[]; +} +export interface CMsgGCCStrike15_v2_PremierSeasonSummary_DataPerWeek { + week_id: string | null; + rank_id: number | null; + matches_played: number | null; +} +export interface CMsgGCCStrike15_v2_PremierSeasonSummary_DataPerMap { + map_id: number | null; + wins: number | null; + ties: number | null; + losses: number | null; + rounds: number | null; + kills: number | null; + headshots: number | null; + assists: number | null; + deaths: number | null; + mvps: number | null; + rounds_3k: number | null; + rounds_4k: number | null; + rounds_5k: number | null; +} +export interface CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats { + gs_location_id: number | null; + data_center_id: number | null; + num_locked_in: number | null; + num_found_nearby: number | null; + note_level: number | null; +} From 5111af9f35e4c36a2f74e7fdd5779e8070f161da Mon Sep 17 00:00:00 2001 From: sak0a Date: Fri, 2 Oct 2026 07:59:34 +0200 Subject: [PATCH 2/3] Validate signed wire values and authoritative inventory snapshot ordering --- handlers.js | 7 +++-- lib/wire-schema.json | 2 +- lib/wire-validation.js | 18 +++++++++-- scripts/generate-wire-schema.js | 7 ++++- scripts/verify-consumers.js | 53 +++++++++++++++++++++++++++------ test/lifecycle.test.mjs | 24 +++++++++++++++ 6 files changed, 96 insertions(+), 15 deletions(-) diff --git a/handlers.js b/handlers.js index 9ad94c8..d7afea2 100644 --- a/handlers.js +++ b/handlers.js @@ -79,6 +79,7 @@ handlers[Language.ClientWelcome] = function (body) { this.inventory = Array.from(new Map(items.map((item) => [item.id, item])).values()); const version = proto.outofdate_subscribed_caches[0].version; + this._soSnapshotVersion = version == null ? null : BigInt(version); this._soVersions = new Map(version == null ? [] : this.inventory.map((item) => [item.id, BigInt(version)])); break; /*case 7: @@ -634,9 +635,11 @@ NodeCS2.prototype._normalizeInspectItem = function (item) { // so an old create cannot resurrect a destroyed item within the current cache. NodeCS2.prototype._acceptSOVersion = function (id, version) { if (version == null) return true; + const incoming = BigInt(version); + if (this._soSnapshotVersion != null && incoming <= this._soSnapshotVersion) return false; this._soVersions = this._soVersions || new Map(); const previous = this._soVersions.get(String(id)); - if (previous !== undefined && BigInt(version) <= previous) return false; - this._soVersions.set(String(id), BigInt(version)); + if (previous !== undefined && incoming <= previous) return false; + this._soVersions.set(String(id), incoming); return true; }; diff --git a/lib/wire-schema.json b/lib/wire-schema.json index 5ae5b0d..a3bb417 100644 --- a/lib/wire-schema.json +++ b/lib/wire-schema.json @@ -1 +1 @@ -{"CMsgGCCStrike15_v2_ClientLogonFatalError":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"CMsgClientWelcome":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"],"3":[2,"CMsgSOCacheSubscribed",false,".CMsgSOCacheSubscribed"],"4":[2,"CMsgSOCacheSubscriptionCheck",false,".CMsgSOCacheSubscriptionCheck"],"5":[2,"CMsgClientWelcome.Location",false,".CMsgClientWelcome.Location"],"6":[2,null,false,"bytes"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"]},"CMsgSOCacheSubscribed":{"2":[2,"CMsgSOCacheSubscribed.SubscribedType",false,".CMsgSOCacheSubscribed.SubscribedType"],"3":[1,null,false,"fixed64"],"4":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOCacheSubscribed.SubscribedType":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]},"CMsgSOIDOwner":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"]},"CMsgSOCacheSubscriptionCheck":{"2":[1,null,false,"fixed64"],"3":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgClientWelcome.Location":{"1":[5,null,false,"float"],"2":[5,null,false,"float"],"3":[2,null,false,"string"]},"CSOEconItem":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"],"12":[2,"CSOEconItemAttribute",false,".CSOEconItemAttribute"],"13":[2,"CSOEconItem",false,".CSOEconItem"],"14":[0,null,false,"bool"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint64"],"18":[2,"CSOEconItemEquipped",false,".CSOEconItemEquipped"],"19":[0,null,false,"uint32"]},"CSOEconItemAttribute":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,null,false,"bytes"]},"CSOEconItemEquipped":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve"],"3":[2,"GlobalStatistics",false,".GlobalStatistics"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"8":[2,"PlayerCommendationInfo",false,".PlayerCommendationInfo"],"9":[2,"PlayerMedalsInfo",false,".PlayerMedalsInfo"],"10":[2,"TournamentEvent",false,".TournamentEvent"],"11":[2,"TournamentTeam",false,".TournamentTeam"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[2,"TournamentEvent",false,".TournamentEvent"],"14":[0,null,false,"uint32"],"15":[2,"AccountActivity",false,".AccountActivity"],"17":[0,null,false,"int32"],"18":[0,null,false,"int32"],"19":[0,null,false,"int32"],"20":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"21":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"6":[2,null,false,"string"],"7":[2,null,false,"string"],"8":[2,"DataCenterPing",false,".DataCenterPing"],"9":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve":{"1":[0,null,true,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"],"5":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,true,"uint32"],"9":[2,"IpAddressMask",false,".IpAddressMask"],"10":[0,null,false,"uint64"],"11":[2,"TournamentEvent",false,".TournamentEvent"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[0,null,true,"uint32"],"14":[0,null,false,"uint64"],"15":[2,"CPreMatchInfoData",false,".CPreMatchInfoData"],"17":[0,null,false,"uint32"],"18":[0,null,false,"uint32"],"19":[2,"OperationalVarValue",false,".OperationalVarValue"],"20":[0,null,false,"uint32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[2,null,false,"string"]},"PlayerRankingInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint64"],"9":[2,null,false,"string"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[2,"PlayerRankingInfo.PerMapRank",false,".PlayerRankingInfo.PerMapRank"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"PlayerRankingInfo.PerMapRank":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"IpAddressMask":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"]},"TournamentEvent":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"]},"TournamentTeam":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,null,false,"string"],"5":[2,"TournamentPlayer",false,".TournamentPlayer"]},"TournamentPlayer":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,null,false,"string"],"6":[2,null,false,"string"],"7":[2,null,false,"string"]},"CPreMatchInfoData":{"1":[0,null,false,"int32"],"4":[2,"CDataGCCStrike15_v2_TournamentMatchDraft",false,".CDataGCCStrike15_v2_TournamentMatchDraft"],"5":[2,"CPreMatchInfoData.TeamStats",false,".CPreMatchInfoData.TeamStats"],"6":[0,null,true,"int32"]},"CDataGCCStrike15_v2_TournamentMatchDraft":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[0,null,false,"int32"],"9":[0,null,false,"int32"],"10":[2,"CDataGCCStrike15_v2_TournamentMatchDraft.Entry",false,".CDataGCCStrike15_v2_TournamentMatchDraft.Entry"],"11":[0,null,true,"int32"],"12":[0,null,true,"int32"],"13":[0,null,true,"int32"],"14":[0,null,true,"int32"],"15":[0,null,true,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,false,"int32"],"19":[5,null,false,"float"],"20":[5,null,false,"float"]},"CDataGCCStrike15_v2_TournamentMatchDraft.Entry":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"]},"CPreMatchInfoData.TeamStats":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"OperationalVarValue":{"1":[2,null,false,"string"],"2":[0,null,false,"int32"],"3":[5,null,false,"float"],"4":[2,null,false,"bytes"]},"DataCenterPing":{"1":[5,null,false,"fixed32"],"2":[0,null,false,"sint32"]},"GlobalStatistics":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[2,"DetailedSearchStatistic",false,".DetailedSearchStatistic"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"DetailedSearchStatistic":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerCommendationInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerMedalsInfo":{"7":[0,null,true,"uint32"],"8":[0,null,false,"uint32"]},"AccountActivity":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"]},"CMsgConnectionStatus":{"1":[0,null,false,".GCConnectionStatus"],"2":[0,null,false,"uint32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchList":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[2,"CDataGCCStrike15_v2_MatchInfo",false,".CDataGCCStrike15_v2_MatchInfo"],"5":[2,"TournamentTeam",false,".TournamentTeam"],"6":[2,"CDataGCCStrike15_v2_TournamentInfo",false,".CDataGCCStrike15_v2_TournamentInfo"]},"CDataGCCStrike15_v2_MatchInfo":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[2,"WatchableMatchInfo",false,".WatchableMatchInfo"],"4":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"]},"WatchableMatchInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[2,null,false,"bytes"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,false,"uint32"],"9":[2,null,false,"string"],"10":[2,null,false,"string"],"11":[0,null,false,"uint64"],"12":[0,null,false,"uint64"],"13":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats":{"1":[0,null,false,"uint64"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"3":[2,null,false,"string"],"4":[0,null,false,"int32"],"5":[0,null,true,"int32"],"6":[0,null,true,"int32"],"7":[0,null,true,"int32"],"8":[0,null,true,"int32"],"9":[0,null,true,"int32"],"10":[0,null,false,"int32"],"11":[0,null,false,"int32"],"12":[0,null,true,"int32"],"13":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm"],"14":[0,null,false,"int32"],"15":[0,null,false,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,true,"int32"],"19":[0,null,true,"int32"],"20":[0,null,true,"int32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[0,null,true,"int32"],"26":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo"],"27":[0,null,false,"bool"],"28":[0,null,true,"int32"],"29":[0,null,true,"int32"],"30":[0,null,true,"int32"],"31":[0,null,false,"uint32"],"32":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo":{"1":[0,null,false,"uint32"]},"CDataGCCStrike15_v2_TournamentInfo":{"1":[2,"CDataGCCStrike15_v2_TournamentSection",false,".CDataGCCStrike15_v2_TournamentSection"],"2":[2,"TournamentEvent",false,".TournamentEvent"],"3":[2,"TournamentTeam",false,".TournamentTeam"]},"CDataGCCStrike15_v2_TournamentSection":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,"CDataGCCStrike15_v2_TournamentGroup",false,".CDataGCCStrike15_v2_TournamentGroup"]},"CDataGCCStrike15_v2_TournamentGroup":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,"CDataGCCStrike15_v2_TournamentGroupTeam",false,".CDataGCCStrike15_v2_TournamentGroupTeam"],"6":[0,null,true,"int32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,"CDataGCCStrike15_v2_TournamentGroup.Picks",false,".CDataGCCStrike15_v2_TournamentGroup.Picks"]},"CDataGCCStrike15_v2_TournamentGroupTeam":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"bool"]},"CDataGCCStrike15_v2_TournamentGroup.Picks":{"1":[0,null,true,"int32"]},"CMsgGCCStrike15_v2_PlayersProfile":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientHello"]},"CMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockResponse":{"1":[2,"CEconItemPreviewDataBlock",false,".CEconItemPreviewDataBlock"]},"CEconItemPreviewDataBlock":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[2,null,false,"string"],"12":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"],"17":[0,null,false,"uint32"],"18":[0,null,false,"int32"],"19":[0,null,false,"uint32"],"20":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"21":[0,null,false,"uint32"],"22":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[2,null,false,"bytes"]},"CEconItemPreviewDataBlock.Sticker":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[5,null,false,"float"],"4":[5,null,false,"float"],"5":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[5,null,false,"float"],"8":[5,null,false,"float"],"9":[5,null,false,"float"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_GC2ClientNotifyXPShop":{"1":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"2":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"CSOAccountXpShop":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint32"]},"CMsgRecurringMissionSchema":{"1":[2,"CMsgRecurringMissionSchema.MissionTemplateList",false,".CMsgRecurringMissionSchema.MissionTemplateList"]},"CMsgRecurringMissionSchema.MissionTemplateList":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"]},"CMsgGCCStrike15_v2_PremierSeasonSummary":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek"],"4":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"]},"CMsgGCItemCustomizationNotification":{"1":[0,null,true,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint64"]},"CMsgSOSingleObject":{"2":[0,null,false,"int32"],"3":[2,null,false,"bytes"],"4":[1,null,false,"fixed64"],"5":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects":{"2":[2,"CMsgSOMultipleObjects.SingleObject",false,".CMsgSOMultipleObjects.SingleObject"],"3":[1,null,false,"fixed64"],"6":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects.SingleObject":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]}} +{"CMsgGCCStrike15_v2_ClientLogonFatalError":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"CMsgClientWelcome":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"],"3":[2,"CMsgSOCacheSubscribed",false,".CMsgSOCacheSubscribed"],"4":[2,"CMsgSOCacheSubscriptionCheck",false,".CMsgSOCacheSubscriptionCheck"],"5":[2,"CMsgClientWelcome.Location",false,".CMsgClientWelcome.Location"],"6":[2,null,false,"bytes"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"]},"CMsgSOCacheSubscribed":{"2":[2,"CMsgSOCacheSubscribed.SubscribedType",false,".CMsgSOCacheSubscribed.SubscribedType"],"3":[1,null,false,"fixed64"],"4":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOCacheSubscribed.SubscribedType":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]},"CMsgSOIDOwner":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"]},"CMsgSOCacheSubscriptionCheck":{"2":[1,null,false,"fixed64"],"3":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgClientWelcome.Location":{"1":[5,null,false,"float"],"2":[5,null,false,"float"],"3":[2,null,false,"string"]},"CSOEconItem":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,null,false,"string"],"11":[2,null,false,"string"],"12":[2,"CSOEconItemAttribute",false,".CSOEconItemAttribute"],"13":[2,"CSOEconItem",false,".CSOEconItem"],"14":[0,null,false,"bool"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint64"],"18":[2,"CSOEconItemEquipped",false,".CSOEconItemEquipped"],"19":[0,null,false,"uint32"]},"CSOEconItemAttribute":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,null,false,"bytes"]},"CSOEconItemEquipped":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve"],"3":[2,"GlobalStatistics",false,".GlobalStatistics"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"8":[2,"PlayerCommendationInfo",false,".PlayerCommendationInfo"],"9":[2,"PlayerMedalsInfo",false,".PlayerMedalsInfo"],"10":[2,"TournamentEvent",false,".TournamentEvent"],"11":[2,"TournamentTeam",false,".TournamentTeam"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[2,"TournamentEvent",false,".TournamentEvent"],"14":[0,null,false,"uint32"],"15":[2,"AccountActivity",false,".AccountActivity"],"17":[0,null,false,"int32"],"18":[0,null,false,"int32"],"19":[0,null,false,"int32"],"20":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"21":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientReserve":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"6":[2,null,false,"string"],"7":[2,null,false,"string"],"8":[2,"DataCenterPing",false,".DataCenterPing"],"9":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve":{"1":[0,null,true,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"],"5":[2,"PlayerRankingInfo",false,".PlayerRankingInfo"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,true,"uint32"],"9":[2,"IpAddressMask",false,".IpAddressMask"],"10":[0,null,false,"uint64"],"11":[2,"TournamentEvent",false,".TournamentEvent"],"12":[2,"TournamentTeam",false,".TournamentTeam"],"13":[0,null,true,"uint32"],"14":[0,null,false,"uint64"],"15":[2,"CPreMatchInfoData",false,".CPreMatchInfoData"],"17":[0,null,false,"uint32"],"18":[0,null,false,"uint32"],"19":[2,"OperationalVarValue",false,".OperationalVarValue"],"20":[0,null,false,"uint32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[2,null,false,"string"]},"PlayerRankingInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint64"],"9":[2,null,false,"string"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[2,"PlayerRankingInfo.PerMapRank",false,".PlayerRankingInfo.PerMapRank"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"PlayerRankingInfo.PerMapRank":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"IpAddressMask":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"]},"TournamentEvent":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"]},"TournamentTeam":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,null,false,"string"],"5":[2,"TournamentPlayer",false,".TournamentPlayer"]},"TournamentPlayer":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,null,false,"string"],"6":[2,null,false,"string"],"7":[2,null,false,"string"]},"CPreMatchInfoData":{"1":[0,null,false,"int32"],"4":[2,"CDataGCCStrike15_v2_TournamentMatchDraft",false,".CDataGCCStrike15_v2_TournamentMatchDraft"],"5":[2,"CPreMatchInfoData.TeamStats",false,".CPreMatchInfoData.TeamStats"],"6":[0,null,true,"int32"]},"CDataGCCStrike15_v2_TournamentMatchDraft":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"],"7":[0,null,false,"int32"],"8":[0,null,false,"int32"],"9":[0,null,false,"int32"],"10":[2,"CDataGCCStrike15_v2_TournamentMatchDraft.Entry",false,".CDataGCCStrike15_v2_TournamentMatchDraft.Entry"],"11":[0,null,true,"int32"],"12":[0,null,true,"int32"],"13":[0,null,true,"int32"],"14":[0,null,true,"int32"],"15":[0,null,true,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,false,"int32"],"19":[5,null,false,"float"],"20":[5,null,false,"float"]},"CDataGCCStrike15_v2_TournamentMatchDraft.Entry":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"]},"CPreMatchInfoData.TeamStats":{"1":[0,null,false,"int32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"]},"OperationalVarValue":{"1":[2,null,false,"string"],"2":[0,null,false,"int32"],"3":[5,null,false,"float"],"4":[2,null,false,"bytes"]},"DataCenterPing":{"1":[5,null,false,"fixed32"],"2":[0,null,false,"sint32"]},"GlobalStatistics":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[2,"DetailedSearchStatistic",false,".DetailedSearchStatistic"],"8":[2,null,false,"string"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"16":[0,null,false,"uint32"]},"DetailedSearchStatistic":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerCommendationInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"PlayerMedalsInfo":{"7":[0,null,true,"uint32"],"8":[0,null,false,"uint32"]},"AccountActivity":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint64"]},"CMsgConnectionStatus":{"1":[0,null,false,"enum"],"2":[0,null,false,"uint32"],"3":[0,null,false,"int32"],"4":[0,null,false,"int32"],"5":[0,null,false,"int32"],"6":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchList":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[2,"CDataGCCStrike15_v2_MatchInfo",false,".CDataGCCStrike15_v2_MatchInfo"],"5":[2,"TournamentTeam",false,".TournamentTeam"],"6":[2,"CDataGCCStrike15_v2_TournamentInfo",false,".CDataGCCStrike15_v2_TournamentInfo"]},"CDataGCCStrike15_v2_MatchInfo":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[2,"WatchableMatchInfo",false,".WatchableMatchInfo"],"4":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"],"5":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats"]},"WatchableMatchInfo":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[2,null,false,"bytes"],"6":[0,null,false,"uint64"],"7":[0,null,false,"uint64"],"8":[0,null,false,"uint32"],"9":[2,null,false,"string"],"10":[2,null,false,"string"],"11":[0,null,false,"uint64"],"12":[0,null,false,"uint64"],"13":[0,null,false,"uint64"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats":{"1":[0,null,false,"uint64"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerReserve"],"3":[2,null,false,"string"],"4":[0,null,false,"int32"],"5":[0,null,true,"int32"],"6":[0,null,true,"int32"],"7":[0,null,true,"int32"],"8":[0,null,true,"int32"],"9":[0,null,true,"int32"],"10":[0,null,false,"int32"],"11":[0,null,false,"int32"],"12":[0,null,true,"int32"],"13":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm",false,".CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm"],"14":[0,null,false,"int32"],"15":[0,null,false,"int32"],"16":[0,null,true,"int32"],"17":[0,null,true,"int32"],"18":[0,null,true,"int32"],"19":[0,null,true,"int32"],"20":[0,null,true,"int32"],"21":[0,null,true,"int32"],"22":[0,null,false,"uint32"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[0,null,true,"int32"],"26":[2,"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo",false,".CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo"],"27":[0,null,false,"bool"],"28":[0,null,true,"int32"],"29":[0,null,true,"int32"],"30":[0,null,true,"int32"],"31":[0,null,false,"uint32"],"32":[0,null,false,"int32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ServerConfirm":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint64"],"4":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingServerRoundStats.DropInfo":{"1":[0,null,false,"uint32"]},"CDataGCCStrike15_v2_TournamentInfo":{"1":[2,"CDataGCCStrike15_v2_TournamentSection",false,".CDataGCCStrike15_v2_TournamentSection"],"2":[2,"TournamentEvent",false,".TournamentEvent"],"3":[2,"TournamentTeam",false,".TournamentTeam"]},"CDataGCCStrike15_v2_TournamentSection":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[2,"CDataGCCStrike15_v2_TournamentGroup",false,".CDataGCCStrike15_v2_TournamentGroup"]},"CDataGCCStrike15_v2_TournamentGroup":{"1":[0,null,false,"uint32"],"2":[2,null,false,"string"],"3":[2,null,false,"string"],"4":[0,null,false,"uint32"],"5":[2,"CDataGCCStrike15_v2_TournamentGroupTeam",false,".CDataGCCStrike15_v2_TournamentGroupTeam"],"6":[0,null,true,"int32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[2,"CDataGCCStrike15_v2_TournamentGroup.Picks",false,".CDataGCCStrike15_v2_TournamentGroup.Picks"]},"CDataGCCStrike15_v2_TournamentGroupTeam":{"1":[0,null,false,"int32"],"2":[0,null,false,"int32"],"3":[0,null,false,"bool"]},"CDataGCCStrike15_v2_TournamentGroup.Picks":{"1":[0,null,true,"int32"]},"CMsgGCCStrike15_v2_PlayersProfile":{"1":[0,null,false,"uint32"],"2":[2,"CMsgGCCStrike15_v2_MatchmakingGC2ClientHello",false,".CMsgGCCStrike15_v2_MatchmakingGC2ClientHello"]},"CMsgGCCStrike15_v2_Client2GCEconPreviewDataBlockResponse":{"1":[2,"CEconItemPreviewDataBlock",false,".CEconItemPreviewDataBlock"]},"CEconItemPreviewDataBlock":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint64"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[2,null,false,"string"],"12":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"13":[0,null,false,"uint32"],"14":[0,null,false,"uint32"],"15":[0,null,false,"uint32"],"16":[0,null,false,"uint32"],"17":[0,null,false,"uint32"],"18":[0,null,false,"int32"],"19":[0,null,false,"uint32"],"20":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"21":[0,null,false,"uint32"],"22":[2,"CEconItemPreviewDataBlock.Sticker",false,".CEconItemPreviewDataBlock.Sticker"],"23":[0,null,false,"uint32"],"24":[0,null,false,"uint32"],"25":[2,null,false,"bytes"]},"CEconItemPreviewDataBlock.Sticker":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[5,null,false,"float"],"4":[5,null,false,"float"],"5":[5,null,false,"float"],"6":[0,null,false,"uint32"],"7":[5,null,false,"float"],"8":[5,null,false,"float"],"9":[5,null,false,"float"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_GC2ClientNotifyXPShop":{"1":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"2":[2,"CSOAccountXpShop",false,".CSOAccountXpShop"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"]},"CSOAccountXpShop":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint32"]},"CMsgRecurringMissionSchema":{"1":[2,"CMsgRecurringMissionSchema.MissionTemplateList",false,".CMsgRecurringMissionSchema.MissionTemplateList"]},"CMsgRecurringMissionSchema.MissionTemplateList":{"1":[0,null,false,"uint32"],"2":[2,null,false,"bytes"]},"CMsgGCCStrike15_v2_PremierSeasonSummary":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek"],"4":[2,"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap",false,".CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerWeek":{"1":[0,null,false,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_PremierSeasonSummary.DataPerMap":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"],"6":[0,null,false,"uint32"],"7":[0,null,false,"uint32"],"8":[0,null,false,"uint32"],"9":[0,null,false,"uint32"],"10":[0,null,false,"uint32"],"11":[0,null,false,"uint32"],"12":[0,null,false,"uint32"],"13":[0,null,false,"uint32"]},"CMsgGCCStrike15_v2_MatchmakingGC2ClientSearchStats":{"1":[0,null,false,"uint32"],"2":[0,null,false,"uint32"],"3":[0,null,false,"uint32"],"4":[0,null,false,"uint32"],"5":[0,null,false,"uint32"]},"CMsgGCItemCustomizationNotification":{"1":[0,null,true,"uint64"],"2":[0,null,false,"uint32"],"3":[0,null,true,"uint64"]},"CMsgSOSingleObject":{"2":[0,null,false,"int32"],"3":[2,null,false,"bytes"],"4":[1,null,false,"fixed64"],"5":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects":{"2":[2,"CMsgSOMultipleObjects.SingleObject",false,".CMsgSOMultipleObjects.SingleObject"],"3":[1,null,false,"fixed64"],"6":[2,"CMsgSOIDOwner",false,".CMsgSOIDOwner"]},"CMsgSOMultipleObjects.SingleObject":{"1":[0,null,false,"int32"],"2":[2,null,false,"bytes"]}} diff --git a/lib/wire-validation.js b/lib/wire-validation.js index f293482..8064da2 100644 --- a/lib/wire-validation.js +++ b/lib/wire-validation.js @@ -1,5 +1,6 @@ 'use strict'; const schemas = require('./wire-schema.json'); +const utf8 = new (require('util').TextDecoder)('utf-8', { fatal: true }); // protobufjs can accept a length-delimited string truncated at the buffer end. // Validate boundaries before decoding, including every known nested message. @@ -19,6 +20,12 @@ module.exports = function validateWire(bytes, name) { } return fail(); } + function integer(value, known) { + if (!known) return; + if (['uint32', 'sint32'].includes(known[3]) && value > 0xffffffffn) return fail(); + // Accept both five-byte negative int32 encodings and proper uint64 sign extension. + if (['int32', 'enum'].includes(known[3]) && value > 0xffffffffn && value < 0xffffffff80000000n) return fail(); + } function scan(end, schema, depth, group) { if (depth > 64) return fail(); while (offset < end) { @@ -34,7 +41,7 @@ module.exports = function validateWire(bytes, name) { if (known && wire !== known[0] && !(known[2] && wire === 2)) return fail(); if (wire === 0) { const value = varint(end); - if (known && ['uint32', 'sint32'].includes(known[3]) && value > 0xffffffffn) return fail(); + integer(value, known); } else if (wire === 1 || wire === 5) { offset += wire === 1 ? 8 : 4; if (offset > end) return fail(); @@ -47,10 +54,17 @@ module.exports = function validateWire(bytes, name) { if (known[0] === 0) { while (offset < limit) { const value = varint(limit); - if (['uint32', 'sint32'].includes(known[3]) && value > 0xffffffffn) return fail(); + integer(value, known); } } else if (Number(length) % (known[0] === 1 ? 8 : 4)) return fail(); } + if (known && known[3] === 'string') { + try { + utf8.decode(bytes.subarray(offset, limit)); + } catch (error) { + return fail(); + } + } offset = limit; } else if (wire === 3) scan(end, null, depth + 1, field); else return fail(); diff --git a/scripts/generate-wire-schema.js b/scripts/generate-wire-schema.js index 45e11b0..5fcdf79 100644 --- a/scripts/generate-wire-schema.js +++ b/scripts/generate-wire-schema.js @@ -30,7 +30,12 @@ function visit(type) { : ['float', 'fixed32', 'sfixed32'].includes(field.type) ? 5 : 0; - fields[field.id] = [wire, nested, !!field.repeated && wire !== 2, field.type]; + fields[field.id] = [ + wire, + nested, + !!field.repeated && wire !== 2, + field.resolvedType instanceof pb.Enum ? 'enum' : field.type + ]; } return name; } diff --git a/scripts/verify-consumers.js b/scripts/verify-consumers.js index 8fc5222..860329c 100644 --- a/scripts/verify-consumers.js +++ b/scripts/verify-consumers.js @@ -13,19 +13,50 @@ function run(command, args, cwd = temporary) { return result.stdout.trim(); } try { - const [packed] = JSON.parse(run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], root)); - assert(packed.files.some(file => file.path === 'types/index.d.ts')); - assert(packed.files.some(file => file.path === 'protobufs/generated/_load.js')); - assert(!packed.files.some(file => /^(?:test|node_modules)\//.test(file.path))); + const [packed] = JSON.parse( + run('npm', ['pack', '--json', '--ignore-scripts', '--pack-destination', temporary], root) + ); + assert(packed.files.some((file) => file.path === 'types/index.d.ts')); + assert(packed.files.some((file) => file.path === 'protobufs/generated/_load.js')); + assert(!packed.files.some((file) => /^(?:test|node_modules)\//.test(file.path))); fs.writeFileSync(path.join(temporary, 'package.json'), JSON.stringify({ private: true })); // Set NODE_CS2_TEST_RUNTIMES=current for a fast smoke check; default tests the // advertised minimum and supported major release lines with actual binaries. const versions = (process.env.NODE_CS2_TEST_RUNTIMES || '14.21.3,16.20.2,18.20.8,20.19.0,22.12.0,24.0.0').split(','); const peers = ['4.2.0', '4.29.3', '5.3.0']; - const runtimeDeps = versions.filter(version => version !== 'current').map(version => `runtime-${version.split('.')[0]}@npm:node@${version}`); - run('npm', ['install', '--no-audit', '--no-fund', path.join(temporary, packed.filename), ...peers.map(version => `steam-${version.replace(/\./g, '-')}@npm:steam-user@${version}`), ...runtimeDeps]); + const runtimeDeps = versions + .filter((version) => version !== 'current') + .map((version) => `runtime-${version.split('.')[0]}@npm:node@${version}`); + run('npm', [ + 'install', + '--no-audit', + '--no-fund', + path.join(temporary, packed.filename), + ...peers.map((version) => `steam-${version.replace(/\./g, '-')}@npm:steam-user@${version}`), + ...runtimeDeps, + 'typescript@5.9.3', + '@types/node@24.19.1', + '@types/steam-user@5.1.1' + ]); + const usage = fs + .readFileSync(path.join(root, 'test/types/usage.ts'), 'utf8') + .replace("require('../..')", "require('node-cs2')"); + fs.writeFileSync(path.join(temporary, 'usage.ts'), usage); + run(process.execPath, [ + path.join(temporary, 'node_modules/typescript/bin/tsc'), + '--strict', + '--noEmit', + '--target', + 'ES2020', + '--module', + 'commonjs', + 'usage.ts' + ]); + console.log('Packed TypeScript declarations and callback/Promise examples passed.'); fs.copyFileSync(path.join(root, 'fixtures/inspect-corpus.json'), path.join(temporary, 'corpus.json')); - fs.writeFileSync(path.join(temporary, 'consumer.cjs'), ` + fs.writeFileSync( + path.join(temporary, 'consumer.cjs'), + ` const assert = require('assert').strict; const NodeCS2 = require('node-cs2'); const Steam = require(process.argv[2]); @@ -42,9 +73,13 @@ const cs2 = new NodeCS2(steam); if (cs2.dispose) cs2.dispose(); console.log(process.version + ' / ' + process.argv[2] + ': 56 independent fixtures passed'); })().catch(error => { console.error(error); process.exitCode = 1; }); -`); +` + ); for (const version of versions) { - const binary = version === 'current' ? process.execPath : path.join(temporary, 'node_modules', `runtime-${version.split('.')[0]}`, 'bin', 'node'); + const binary = + version === 'current' + ? process.execPath + : path.join(temporary, 'node_modules', `runtime-${version.split('.')[0]}`, 'bin', 'node'); for (const peer of peers) console.log(run(binary, ['consumer.cjs', `steam-${peer.replace(/\./g, '-')}`])); } console.log(`Validated packed ${packed.filename}; Node runtime and steam-user matrix complete.`); diff --git a/test/lifecycle.test.mjs b/test/lifecycle.test.mjs index bcec7e6..6669a5d 100644 --- a/test/lifecycle.test.mjs +++ b/test/lifecycle.test.mjs @@ -257,3 +257,27 @@ it('rejects incoming known uint32 overflow and ignores missing connection status steam.receive(L.Client2GCEconPreviewDataBlockResponse, bytes(1, [...uint(2, 11), ...uint(3, '4294967296')])); expect(errors).toHaveBeenCalledOnce(); }); +it('rejects int32 overflow and invalid UTF-8, while preserving signed encodings', () => { + const errors = vi.fn(), + items = vi.fn(); + cs.on('error', errors); + cs.on('inspectItemInfo', items); + const response = (fields) => bytes(1, [...uint(2, 11), ...fields]); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, response(uint(18, '4294967296'))); + steam.receive(L.Client2GCEconPreviewDataBlockResponse, response(bytes(11, [0xc0, 0xaf]))); + expect(errors).toHaveBeenCalledTimes(2); + expect(items).not.toHaveBeenCalled(); + for (const value of ['4294967295', '18446744073709551615']) { + steam.receive(L.Client2GCEconPreviewDataBlockResponse, response(uint(18, value))); + } + expect(items.mock.calls.map((call) => call[0].entindex)).toEqual([-1, -1]); +}); +it('authoritative empty inventory snapshot rejects stale unknown creates and updates', () => { + steam.receive(L.ClientWelcome, bytes(3, [...bytes(2, uint(1, 1)), ...fixed64(3, 10)])); + const so = (v) => [...uint(2, 1), ...bytes(3, uint(1, 11)), ...fixed64(4, v)]; + steam.receive(L.SO_Create, so(9)); + steam.receive(L.SO_Update, so(10)); + expect(cs.inventory).toEqual([]); + steam.receive(L.SO_Create, so(11)); + expect(cs.inventory).toHaveLength(1); +}); From f868ff270079c8eddaac7b252af43e9170dc7538 Mon Sep 17 00:00:00 2001 From: sak0a Date: Fri, 2 Oct 2026 08:00:32 +0200 Subject: [PATCH 3/3] Exercise GC dispatch and cancellation with every packed Steam peer --- scripts/verify-consumers.js | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/scripts/verify-consumers.js b/scripts/verify-consumers.js index 860329c..7bd2118 100644 --- a/scripts/verify-consumers.js +++ b/scripts/verify-consumers.js @@ -69,6 +69,21 @@ const cs2 = new NodeCS2(steam); assert.equal(item.itemid, fixture.expected.itemid); assert.equal(item.paintwear, fixture.expected.paintwear); } + + // Exercise the real peer EventEmitter contract as well as local decoding. + steam.steamID = new (require('steamid'))('76561198000000001'); + const sent = []; + steam.sendToGC = (appid, type, headers, payload) => sent.push({appid, type, headers, payload}); + const inspection = cs2.inspectItem('M1A11D1'); + assert.equal(sent[0].appid, 730); + assert.equal(Buffer.from(sent[0].payload).toString('hex'), '0800100b18012001'); + const language = require('node-cs2/language.js'); + steam.emit('receivedFromGC', 730, language.Client2GCEconPreviewDataBlockResponse, Buffer.from([10, 2, 16, 11])); + assert.equal((await inspection).itemid, '11'); + const disconnected = cs2.inspectItem('M1A12D1').then(() => { throw new Error('Expected disconnection'); }, error => error); + steam.emit('disconnected'); + assert.equal((await disconnected).code, 'DISCONNECTED'); + assert.equal(cs2.listenerCount('inspectItemInfo#12'), 0); assert.equal(typeof cs2.inspectItem, 'function'); if (cs2.dispose) cs2.dispose(); console.log(process.version + ' / ' + process.argv[2] + ': 56 independent fixtures passed');