@vlydev/cs2-masked-inspect 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/tests.yml +28 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/index.js +7 -0
- package/package.json +19 -0
- package/src/InspectLink.js +320 -0
- package/src/ItemPreviewData.js +84 -0
- package/src/Sticker.js +48 -0
- package/src/proto/reader.js +128 -0
- package/src/proto/writer.js +137 -0
- package/tests/inspect_link.test.js +361 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: ["**"]
|
|
6
|
+
pull_request:
|
|
7
|
+
branches: ["**"]
|
|
8
|
+
|
|
9
|
+
jobs:
|
|
10
|
+
test:
|
|
11
|
+
runs-on: ubuntu-latest
|
|
12
|
+
|
|
13
|
+
strategy:
|
|
14
|
+
matrix:
|
|
15
|
+
node: ["18", "20", "22"]
|
|
16
|
+
|
|
17
|
+
name: Node.js ${{ matrix.node }}
|
|
18
|
+
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Setup Node.js
|
|
23
|
+
uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: ${{ matrix.node }}
|
|
26
|
+
|
|
27
|
+
- name: Run tests
|
|
28
|
+
run: npm test
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VlyDev
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# cs2-masked-inspect (JavaScript)
|
|
2
|
+
|
|
3
|
+
Pure JavaScript library for encoding and decoding CS2 masked inspect links — no external dependencies.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @vlydev/cs2-masked-inspect
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
### Deserialize a CS2 inspect link
|
|
14
|
+
|
|
15
|
+
```javascript
|
|
16
|
+
const { InspectLink } = require('@vlydev/cs2-masked-inspect');
|
|
17
|
+
|
|
18
|
+
// Accepts a full steam:// URL or a raw hex string
|
|
19
|
+
const item = InspectLink.deserialize(
|
|
20
|
+
'steam://run/730//+csgo_econ_action_preview%20E3F3367440334DE2FBE4C345E0CBE0D3...'
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
console.log(item.defIndex); // 7 (AK-47)
|
|
24
|
+
console.log(item.paintIndex); // 422
|
|
25
|
+
console.log(item.paintSeed); // 922
|
|
26
|
+
console.log(item.paintWear); // ~0.04121
|
|
27
|
+
console.log(item.itemId); // 46876117973
|
|
28
|
+
|
|
29
|
+
item.stickers.forEach(s => console.log(s.stickerId));
|
|
30
|
+
// 7436, 5144, 6970, 8069, 5592
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Serialize an item to a hex payload
|
|
34
|
+
|
|
35
|
+
```javascript
|
|
36
|
+
const { InspectLink, ItemPreviewData } = require('@vlydev/cs2-masked-inspect');
|
|
37
|
+
|
|
38
|
+
const data = new ItemPreviewData({
|
|
39
|
+
defIndex: 60,
|
|
40
|
+
paintIndex: 440,
|
|
41
|
+
paintSeed: 353,
|
|
42
|
+
paintWear: 0.005411375779658556,
|
|
43
|
+
rarity: 5,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const hex = InspectLink.serialize(data);
|
|
47
|
+
// 00183C20B803280538E9A3C5DD0340E102C246A0D1
|
|
48
|
+
|
|
49
|
+
const url = `steam://run/730//+csgo_econ_action_preview%20${hex}`;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Item with stickers and keychains
|
|
53
|
+
|
|
54
|
+
```javascript
|
|
55
|
+
const { InspectLink, ItemPreviewData, Sticker } = require('@vlydev/cs2-masked-inspect');
|
|
56
|
+
|
|
57
|
+
const data = new ItemPreviewData({
|
|
58
|
+
defIndex: 7,
|
|
59
|
+
paintIndex: 422,
|
|
60
|
+
paintSeed: 922,
|
|
61
|
+
paintWear: 0.04121,
|
|
62
|
+
rarity: 3,
|
|
63
|
+
quality: 4,
|
|
64
|
+
stickers: [
|
|
65
|
+
new Sticker({ slot: 0, stickerId: 7436 }),
|
|
66
|
+
new Sticker({ slot: 1, stickerId: 5144, wear: 0.1 }),
|
|
67
|
+
],
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const hex = InspectLink.serialize(data);
|
|
71
|
+
const decoded = InspectLink.deserialize(hex); // round-trip
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Validation
|
|
77
|
+
|
|
78
|
+
Use `InspectLink.isMasked()` and `InspectLink.isClassic()` to detect the link type without decoding it.
|
|
79
|
+
|
|
80
|
+
```javascript
|
|
81
|
+
const { InspectLink } = require('@vlydev/cs2-masked-inspect');
|
|
82
|
+
|
|
83
|
+
// New masked format (pure hex blob) — can be decoded offline
|
|
84
|
+
const maskedUrl = 'steam://run/730//+csgo_econ_action_preview%20E3F3...';
|
|
85
|
+
InspectLink.isMasked(maskedUrl); // true
|
|
86
|
+
InspectLink.isClassic(maskedUrl); // false
|
|
87
|
+
|
|
88
|
+
// Hybrid format (S/A/D prefix with hex proto after D) — also decodable offline
|
|
89
|
+
const hybridUrl = 'steam://rungame/730/.../+csgo_econ_action_preview%20S76561199323320483A50075495125D1101C4C4FCD4AB10...';
|
|
90
|
+
InspectLink.isMasked(hybridUrl); // true
|
|
91
|
+
InspectLink.isClassic(hybridUrl); // false
|
|
92
|
+
|
|
93
|
+
// Classic format — requires Steam Game Coordinator to fetch item info
|
|
94
|
+
const classicUrl = 'steam://rungame/730/.../+csgo_econ_action_preview%20S76561199842063946A49749521570D2751293026650298712';
|
|
95
|
+
InspectLink.isMasked(classicUrl); // false
|
|
96
|
+
InspectLink.isClassic(classicUrl); // true
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
## How the format works
|
|
102
|
+
|
|
103
|
+
Three URL formats are handled:
|
|
104
|
+
|
|
105
|
+
1. **New masked format** — pure hex blob after `csgo_econ_action_preview`:
|
|
106
|
+
```
|
|
107
|
+
steam://run/730//+csgo_econ_action_preview%20<hexbytes>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
2. **Hybrid format** — old-style `S/A/D` prefix, but with a hex proto appended after `D` (instead of a decimal did):
|
|
111
|
+
```
|
|
112
|
+
steam://rungame/730/.../+csgo_econ_action_preview%20S<steamid>A<assetid>D<hexproto>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
3. **Classic format** — old-style `S/A/D` with a decimal did; requires Steam GC to resolve item details.
|
|
116
|
+
|
|
117
|
+
For formats 1 and 2 the library decodes the item offline. For format 3 only URL parsing is possible.
|
|
118
|
+
|
|
119
|
+
The hex blob (formats 1 and 2) has the following binary layout:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
[key_byte] [proto_bytes XOR'd with key] [4-byte checksum XOR'd with key]
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
| Section | Size | Description |
|
|
126
|
+
|---------|------|-------------|
|
|
127
|
+
| `key_byte` | 1 byte | XOR key. `0x00` = no obfuscation (tool links). Other values = native CS2 links. |
|
|
128
|
+
| `proto_bytes` | variable | `CEconItemPreviewDataBlock` protobuf, each byte XOR'd with `key_byte`. |
|
|
129
|
+
| `checksum` | 4 bytes | Big-endian uint32, XOR'd with `key_byte`. |
|
|
130
|
+
|
|
131
|
+
### Checksum algorithm
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
const buffer = Buffer.concat([Buffer.from([0x00]), protoBytes]);
|
|
135
|
+
const crc = crc32(buffer); // standard CRC32
|
|
136
|
+
const xored = ((crc & 0xFFFF) ^ (protoBytes.length * crc)) >>> 0;
|
|
137
|
+
const checksum = Buffer.alloc(4);
|
|
138
|
+
checksum.writeUInt32BE(xored, 0); // big-endian uint32
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### `paintWear` encoding
|
|
142
|
+
|
|
143
|
+
`paintWear` is stored as a `uint32` varint whose bit pattern is the IEEE 754 representation
|
|
144
|
+
of a `float32`. The library handles this transparently — callers always work with regular
|
|
145
|
+
JavaScript `number` values.
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Proto field reference
|
|
150
|
+
|
|
151
|
+
### CEconItemPreviewDataBlock
|
|
152
|
+
|
|
153
|
+
| Field | Number | Type | JS property |
|
|
154
|
+
|-------|--------|------|-------------|
|
|
155
|
+
| `accountid` | 1 | uint32 | `accountId` |
|
|
156
|
+
| `itemid` | 2 | uint64 | `itemId` |
|
|
157
|
+
| `defindex` | 3 | uint32 | `defIndex` |
|
|
158
|
+
| `paintindex` | 4 | uint32 | `paintIndex` |
|
|
159
|
+
| `rarity` | 5 | uint32 | `rarity` |
|
|
160
|
+
| `quality` | 6 | uint32 | `quality` |
|
|
161
|
+
| `paintwear` | 7 | uint32* | `paintWear` (float32 reinterpreted as uint32) |
|
|
162
|
+
| `paintseed` | 8 | uint32 | `paintSeed` |
|
|
163
|
+
| `killeaterscoretype` | 9 | uint32 | `killEaterScoreType` |
|
|
164
|
+
| `killeatervalue` | 10 | uint32 | `killEaterValue` |
|
|
165
|
+
| `customname` | 11 | string | `customName` |
|
|
166
|
+
| `stickers` | 12 | repeated Sticker | `stickers` |
|
|
167
|
+
| `inventory` | 13 | uint32 | `inventory` |
|
|
168
|
+
| `origin` | 14 | uint32 | `origin` |
|
|
169
|
+
| `questid` | 15 | uint32 | `questId` |
|
|
170
|
+
| `dropreason` | 16 | uint32 | `dropReason` |
|
|
171
|
+
| `musicindex` | 17 | uint32 | `musicIndex` |
|
|
172
|
+
| `entindex` | 18 | int32 | `entIndex` |
|
|
173
|
+
| `petindex` | 19 | uint32 | `petIndex` |
|
|
174
|
+
| `keychains` | 20 | repeated Sticker | `keychains` |
|
|
175
|
+
|
|
176
|
+
### Sticker
|
|
177
|
+
|
|
178
|
+
| Field | Number | Type | JS property |
|
|
179
|
+
|-------|--------|------|-------------|
|
|
180
|
+
| `slot` | 1 | uint32 | `slot` |
|
|
181
|
+
| `sticker_id` | 2 | uint32 | `stickerId` |
|
|
182
|
+
| `wear` | 3 | float32 | `wear` |
|
|
183
|
+
| `scale` | 4 | float32 | `scale` |
|
|
184
|
+
| `rotation` | 5 | float32 | `rotation` |
|
|
185
|
+
| `tint_id` | 6 | uint32 | `tintId` |
|
|
186
|
+
| `offset_x` | 7 | float32 | `offsetX` |
|
|
187
|
+
| `offset_y` | 8 | float32 | `offsetY` |
|
|
188
|
+
| `offset_z` | 9 | float32 | `offsetZ` |
|
|
189
|
+
| `pattern` | 10 | uint32 | `pattern` |
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Known test vectors
|
|
194
|
+
|
|
195
|
+
### Vector 1 — Native CS2 link (XOR key 0xE3)
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
E3F3367440334DE2FBE4C345E0CBE0D3E7DB6943400AE0A379E481ECEBE2F36F
|
|
199
|
+
D9DE2BDB515EA6E30D74D981ECEBE3F37BCBDE640D475DA6E35EFCD881ECEBE3
|
|
200
|
+
F359D5DE37E9D75DA6436DD3DD81ECEBE3F366DCDE3F8F9BDDA69B43B6DE81EC
|
|
201
|
+
EBE3F33BC8DEBB1CA3DFA623F7DDDF8B71E293EBFD43382B
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| Field | Value |
|
|
205
|
+
|-------|-------|
|
|
206
|
+
| `itemId` | `46876117973` |
|
|
207
|
+
| `defIndex` | `7` (AK-47) |
|
|
208
|
+
| `paintIndex` | `422` |
|
|
209
|
+
| `paintSeed` | `922` |
|
|
210
|
+
| `paintWear` | `≈ 0.04121` |
|
|
211
|
+
| `rarity` | `3` |
|
|
212
|
+
| `quality` | `4` |
|
|
213
|
+
| sticker IDs | `[7436, 5144, 6970, 8069, 5592]` |
|
|
214
|
+
|
|
215
|
+
### Vector 2 — Tool-generated link (key 0x00)
|
|
216
|
+
|
|
217
|
+
```javascript
|
|
218
|
+
new ItemPreviewData({ defIndex: 60, paintIndex: 440, paintSeed: 353,
|
|
219
|
+
paintWear: 0.005411375779658556, rarity: 5 })
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Expected hex:
|
|
223
|
+
|
|
224
|
+
```
|
|
225
|
+
00183C20B803280538E9A3C5DD0340E102C246A0D1
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## Running tests
|
|
231
|
+
|
|
232
|
+
```bash
|
|
233
|
+
npm test
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
36 tests using the Node.js built-in `node:test` runner — no external test framework required.
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## Contributing
|
|
241
|
+
|
|
242
|
+
Bug reports and pull requests are welcome on [GitHub](https://github.com/vlydev/cs2-masked-inspect-js).
|
|
243
|
+
|
|
244
|
+
1. Fork the repository
|
|
245
|
+
2. Create a branch: `git checkout -b my-fix`
|
|
246
|
+
3. Make your changes and add tests
|
|
247
|
+
4. Ensure all tests pass: `npm test`
|
|
248
|
+
5. Open a Pull Request
|
|
249
|
+
|
|
250
|
+
All PRs require the CI checks to pass before merging.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Author
|
|
255
|
+
|
|
256
|
+
[VlyDev](https://github.com/vlydev) — vladdnepr1989@gmail.com
|
|
257
|
+
|
|
258
|
+
---
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
MIT © [VlyDev](https://github.com/vlydev)
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vlydev/cs2-masked-inspect",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Offline decoder/encoder for CS2 masked inspect URLs — pure JavaScript, no dependencies",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test tests/inspect_link.test.js"
|
|
8
|
+
},
|
|
9
|
+
"keywords": ["cs2", "csgo", "inspect", "protobuf", "steam"],
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "VlyDev",
|
|
12
|
+
"email": "vladdnepr1989@gmail.com",
|
|
13
|
+
"url": "https://github.com/vlydev"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Encodes and decodes CS2 masked inspect links.
|
|
5
|
+
*
|
|
6
|
+
* Binary format:
|
|
7
|
+
* [key_byte] [proto_bytes XOR'd with key] [4-byte checksum XOR'd with key]
|
|
8
|
+
*
|
|
9
|
+
* For tool-generated links key_byte = 0x00 (no XOR needed).
|
|
10
|
+
* For native CS2 links key_byte != 0x00 — every byte must be XOR'd before parsing.
|
|
11
|
+
*
|
|
12
|
+
* Checksum:
|
|
13
|
+
* buffer = [0x00] + proto_bytes
|
|
14
|
+
* crc = crc32(buffer)
|
|
15
|
+
* xored = (crc & 0xffff) ^ (len(proto_bytes) * crc) [unsigned 32-bit]
|
|
16
|
+
* checksum = big-endian uint32 of (xored & 0xFFFFFFFF)
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const ItemPreviewData = require('./ItemPreviewData');
|
|
20
|
+
const Sticker = require('./Sticker');
|
|
21
|
+
const { ProtoReader } = require('./proto/reader');
|
|
22
|
+
const { ProtoWriter } = require('./proto/writer');
|
|
23
|
+
|
|
24
|
+
// ------------------------------------------------------------------
|
|
25
|
+
// CRC32 (standard polynomial 0xEDB88320 — same as zlib/PHP crc32)
|
|
26
|
+
// ------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
/** @type {Uint32Array} */
|
|
29
|
+
const CRC32_TABLE = (() => {
|
|
30
|
+
const table = new Uint32Array(256);
|
|
31
|
+
for (let i = 0; i < 256; i++) {
|
|
32
|
+
let c = i;
|
|
33
|
+
for (let j = 0; j < 8; j++) {
|
|
34
|
+
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
35
|
+
}
|
|
36
|
+
table[i] = c;
|
|
37
|
+
}
|
|
38
|
+
return table;
|
|
39
|
+
})();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {Buffer} buf
|
|
43
|
+
* @returns {number} unsigned 32-bit CRC32
|
|
44
|
+
*/
|
|
45
|
+
function crc32(buf) {
|
|
46
|
+
let crc = 0xFFFFFFFF;
|
|
47
|
+
for (let i = 0; i < buf.length; i++) {
|
|
48
|
+
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ buf[i]) & 0xFF];
|
|
49
|
+
}
|
|
50
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ------------------------------------------------------------------
|
|
54
|
+
// Checksum helpers
|
|
55
|
+
// ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* @param {Buffer} buffer the [0x00] + proto_bytes buffer
|
|
59
|
+
* @param {number} protoLen
|
|
60
|
+
* @returns {Buffer} 4-byte big-endian checksum
|
|
61
|
+
*/
|
|
62
|
+
function computeChecksum(buffer, protoLen) {
|
|
63
|
+
const crcVal = crc32(buffer);
|
|
64
|
+
const val = BigInt(((crcVal & 0xFFFF) ^ (protoLen * crcVal)) >>> 0) & 0xFFFFFFFFn;
|
|
65
|
+
const result = Buffer.alloc(4);
|
|
66
|
+
result.writeUInt32BE(Number(val), 0);
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ------------------------------------------------------------------
|
|
71
|
+
// float32 <-> uint32 reinterpretation
|
|
72
|
+
// ------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
/** @param {number} f @returns {number} */
|
|
75
|
+
function float32ToUint32(f) {
|
|
76
|
+
const dv = new DataView(new ArrayBuffer(4));
|
|
77
|
+
dv.setFloat32(0, f, true); // little-endian
|
|
78
|
+
return dv.getUint32(0, true);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {number} u @returns {number} */
|
|
82
|
+
function uint32ToFloat32(u) {
|
|
83
|
+
const dv = new DataView(new ArrayBuffer(4));
|
|
84
|
+
dv.setUint32(0, u >>> 0, true);
|
|
85
|
+
return dv.getFloat32(0, true);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ------------------------------------------------------------------
|
|
89
|
+
// URL extraction
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
const INSPECT_URL_RE = /(?:%20|\s|\+)A([0-9A-Fa-f]+)/i;
|
|
93
|
+
const HYBRID_URL_RE = /S\d+A\d+D([0-9A-Fa-f]+)$/i;
|
|
94
|
+
const CLASSIC_URL_RE = /csgo_econ_action_preview(?:%20|\s)[SM]\d+A\d+D\d+$/i;
|
|
95
|
+
const MASKED_URL_RE = /csgo_econ_action_preview(?:%20|\s)[0-9A-Fa-f]{10,}$/i;
|
|
96
|
+
|
|
97
|
+
/** @param {string} input @returns {string} */
|
|
98
|
+
function extractHex(input) {
|
|
99
|
+
const stripped = input.trim();
|
|
100
|
+
|
|
101
|
+
// Hybrid format: S\d+A\d+D<hexproto>
|
|
102
|
+
const mh = stripped.match(HYBRID_URL_RE);
|
|
103
|
+
if (mh && /[A-Fa-f]/.test(mh[1])) return mh[1];
|
|
104
|
+
|
|
105
|
+
// Classic/market URL: A<hex> preceded by %20, space, or + (A is a prefix marker, not hex)
|
|
106
|
+
const m = stripped.match(INSPECT_URL_RE);
|
|
107
|
+
if (m) return m[1];
|
|
108
|
+
|
|
109
|
+
// Pure masked format: csgo_econ_action_preview%20<hexblob> (no S/A/M prefix)
|
|
110
|
+
const mm = stripped.match(/csgo_econ_action_preview(?:%20|\s|\+)([0-9A-Fa-f]{10,})$/i);
|
|
111
|
+
if (mm) return mm[1];
|
|
112
|
+
|
|
113
|
+
// Bare hex — strip whitespace
|
|
114
|
+
return stripped.replace(/\s+/g, '');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ------------------------------------------------------------------
|
|
118
|
+
// Sticker encode / decode
|
|
119
|
+
// ------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/** @param {Sticker} s @returns {Buffer} */
|
|
122
|
+
function encodeSticker(s) {
|
|
123
|
+
const w = new ProtoWriter();
|
|
124
|
+
w.writeUint32(1, s.slot);
|
|
125
|
+
w.writeUint32(2, s.stickerId);
|
|
126
|
+
if (s.wear !== null) w.writeFloat32Fixed(3, s.wear);
|
|
127
|
+
if (s.scale !== null) w.writeFloat32Fixed(4, s.scale);
|
|
128
|
+
if (s.rotation !== null) w.writeFloat32Fixed(5, s.rotation);
|
|
129
|
+
w.writeUint32(6, s.tintId);
|
|
130
|
+
if (s.offsetX !== null) w.writeFloat32Fixed(7, s.offsetX);
|
|
131
|
+
if (s.offsetY !== null) w.writeFloat32Fixed(8, s.offsetY);
|
|
132
|
+
if (s.offsetZ !== null) w.writeFloat32Fixed(9, s.offsetZ);
|
|
133
|
+
w.writeUint32(10, s.pattern);
|
|
134
|
+
return w.toBytes();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** @param {Buffer} data @returns {Sticker} */
|
|
138
|
+
function decodeSticker(data) {
|
|
139
|
+
const reader = new ProtoReader(data);
|
|
140
|
+
const s = new Sticker();
|
|
141
|
+
|
|
142
|
+
for (const f of reader.readAllFields()) {
|
|
143
|
+
switch (f.field) {
|
|
144
|
+
case 1: s.slot = Number(f.value); break;
|
|
145
|
+
case 2: s.stickerId = Number(f.value); break;
|
|
146
|
+
case 3: s.wear = f.value.readFloatLE(0); break;
|
|
147
|
+
case 4: s.scale = f.value.readFloatLE(0); break;
|
|
148
|
+
case 5: s.rotation = f.value.readFloatLE(0); break;
|
|
149
|
+
case 6: s.tintId = Number(f.value); break;
|
|
150
|
+
case 7: s.offsetX = f.value.readFloatLE(0); break;
|
|
151
|
+
case 8: s.offsetY = f.value.readFloatLE(0); break;
|
|
152
|
+
case 9: s.offsetZ = f.value.readFloatLE(0); break;
|
|
153
|
+
case 10: s.pattern = Number(f.value); break;
|
|
154
|
+
default: break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ------------------------------------------------------------------
|
|
162
|
+
// ItemPreviewData encode / decode
|
|
163
|
+
// ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/** @param {ItemPreviewData} item @returns {Buffer} */
|
|
166
|
+
function encodeItem(item) {
|
|
167
|
+
const w = new ProtoWriter();
|
|
168
|
+
w.writeUint32(1, item.accountId);
|
|
169
|
+
w.writeUint64(2, item.itemId);
|
|
170
|
+
w.writeUint32(3, item.defIndex);
|
|
171
|
+
w.writeUint32(4, item.paintIndex);
|
|
172
|
+
w.writeUint32(5, item.rarity);
|
|
173
|
+
w.writeUint32(6, item.quality);
|
|
174
|
+
|
|
175
|
+
// paintwear: float32 reinterpreted as uint32 varint
|
|
176
|
+
const pwUint32 = float32ToUint32(item.paintWear);
|
|
177
|
+
w.writeUint32(7, pwUint32);
|
|
178
|
+
|
|
179
|
+
w.writeUint32(8, item.paintSeed);
|
|
180
|
+
w.writeUint32(9, item.killEaterScoreType);
|
|
181
|
+
w.writeUint32(10, item.killEaterValue);
|
|
182
|
+
w.writeString(11, item.customName);
|
|
183
|
+
|
|
184
|
+
for (const sticker of item.stickers) {
|
|
185
|
+
w.writeRawBytes(12, encodeSticker(sticker));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
w.writeUint32(13, item.inventory);
|
|
189
|
+
w.writeUint32(14, item.origin);
|
|
190
|
+
w.writeUint32(15, item.questId);
|
|
191
|
+
w.writeUint32(16, item.dropReason);
|
|
192
|
+
w.writeUint32(17, item.musicIndex);
|
|
193
|
+
w.writeInt32(18, item.entIndex);
|
|
194
|
+
w.writeUint32(19, item.petIndex);
|
|
195
|
+
|
|
196
|
+
for (const kc of item.keychains) {
|
|
197
|
+
w.writeRawBytes(20, encodeSticker(kc));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return w.toBytes();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** @param {Buffer} data @returns {ItemPreviewData} */
|
|
204
|
+
function decodeItem(data) {
|
|
205
|
+
const reader = new ProtoReader(data);
|
|
206
|
+
const item = new ItemPreviewData();
|
|
207
|
+
|
|
208
|
+
for (const f of reader.readAllFields()) {
|
|
209
|
+
switch (f.field) {
|
|
210
|
+
case 1: item.accountId = Number(f.value); break;
|
|
211
|
+
case 2: item.itemId = Number(f.value); break;
|
|
212
|
+
case 3: item.defIndex = Number(f.value); break;
|
|
213
|
+
case 4: item.paintIndex = Number(f.value); break;
|
|
214
|
+
case 5: item.rarity = Number(f.value); break;
|
|
215
|
+
case 6: item.quality = Number(f.value); break;
|
|
216
|
+
case 7: item.paintWear = uint32ToFloat32(Number(f.value)); break;
|
|
217
|
+
case 8: item.paintSeed = Number(f.value); break;
|
|
218
|
+
case 9: item.killEaterScoreType = Number(f.value); break;
|
|
219
|
+
case 10: item.killEaterValue = Number(f.value); break;
|
|
220
|
+
case 11: item.customName = f.value.toString('utf8'); break;
|
|
221
|
+
case 12: item.stickers.push(decodeSticker(f.value)); break;
|
|
222
|
+
case 13: item.inventory = Number(f.value); break;
|
|
223
|
+
case 14: item.origin = Number(f.value); break;
|
|
224
|
+
case 15: item.questId = Number(f.value); break;
|
|
225
|
+
case 16: item.dropReason = Number(f.value); break;
|
|
226
|
+
case 17: item.musicIndex = Number(f.value); break;
|
|
227
|
+
case 18: item.entIndex = Number(f.value); break;
|
|
228
|
+
case 19: item.petIndex = Number(f.value); break;
|
|
229
|
+
case 20: item.keychains.push(decodeSticker(f.value)); break;
|
|
230
|
+
default: break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return item;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ------------------------------------------------------------------
|
|
238
|
+
// Public API
|
|
239
|
+
// ------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
class InspectLink {
|
|
242
|
+
/**
|
|
243
|
+
* Encode an ItemPreviewData to an uppercase hex inspect-link payload.
|
|
244
|
+
*
|
|
245
|
+
* The returned string can be appended to a steam:// inspect URL or used
|
|
246
|
+
* standalone. The key_byte is always 0x00 (no XOR applied).
|
|
247
|
+
*
|
|
248
|
+
* @param {ItemPreviewData} data
|
|
249
|
+
* @returns {string} uppercase hex string
|
|
250
|
+
*/
|
|
251
|
+
static serialize(data) {
|
|
252
|
+
const protoBytes = encodeItem(data);
|
|
253
|
+
const buffer = Buffer.concat([Buffer.from([0x00]), protoBytes]);
|
|
254
|
+
const checksum = computeChecksum(buffer, protoBytes.length);
|
|
255
|
+
return Buffer.concat([buffer, checksum]).toString('hex').toUpperCase();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Returns true if the link contains a decodable protobuf payload (can be decoded offline).
|
|
260
|
+
* @param {string} link
|
|
261
|
+
* @returns {boolean}
|
|
262
|
+
*/
|
|
263
|
+
static isMasked(link) {
|
|
264
|
+
const s = link.trim();
|
|
265
|
+
if (MASKED_URL_RE.test(s)) return true;
|
|
266
|
+
const m = s.match(HYBRID_URL_RE);
|
|
267
|
+
return !!(m && /[A-Fa-f]/.test(m[1]));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Returns true if the link is a classic S/A/D inspect URL with decimal did.
|
|
272
|
+
* @param {string} link
|
|
273
|
+
* @returns {boolean}
|
|
274
|
+
*/
|
|
275
|
+
static isClassic(link) {
|
|
276
|
+
return CLASSIC_URL_RE.test(link.trim());
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Decode an inspect-link hex payload (or full URL) into an ItemPreviewData.
|
|
281
|
+
*
|
|
282
|
+
* Accepts:
|
|
283
|
+
* - A raw uppercase or lowercase hex string
|
|
284
|
+
* - A full steam://rungame/... inspect URL
|
|
285
|
+
* - A CS2-style csgo://rungame/... URL
|
|
286
|
+
*
|
|
287
|
+
* Handles the XOR obfuscation used in native CS2 links.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} input
|
|
290
|
+
* @returns {ItemPreviewData}
|
|
291
|
+
*/
|
|
292
|
+
static deserialize(input) {
|
|
293
|
+
const hex = extractHex(input);
|
|
294
|
+
const raw = Buffer.from(hex, 'hex');
|
|
295
|
+
|
|
296
|
+
if (raw.length < 6) {
|
|
297
|
+
throw new TypeError(
|
|
298
|
+
`Payload too short or invalid hex: "${input}"`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const key = raw[0];
|
|
303
|
+
let decrypted;
|
|
304
|
+
|
|
305
|
+
if (key === 0) {
|
|
306
|
+
decrypted = raw;
|
|
307
|
+
} else {
|
|
308
|
+
decrypted = Buffer.alloc(raw.length);
|
|
309
|
+
for (let i = 0; i < raw.length; i++) {
|
|
310
|
+
decrypted[i] = raw[i] ^ key;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Layout: [key_byte] [proto_bytes] [4-byte checksum]
|
|
315
|
+
const protoBytes = decrypted.slice(1, decrypted.length - 4);
|
|
316
|
+
return decodeItem(protoBytes);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
module.exports = InspectLink;
|