Декодирование inspect-ссылок CS2 локально, без API
С марта 2026 inspect-ссылка CS2 несёт предмет внутри себя. Разбор payload, карта полей protobuf, CRC и XOR-маска, побайтовый пример и рабочий декодер на Python из одной стандартной библиотеки.
Короткий ответ: чтобы прочитать float и paint seed современной inspect-ссылки CS2, API больше не нужен. С марта 2026 ссылка несёт данные предмета внутри себя — шестнадцатеричный хвост после csgo_econ_action_preview это сериализованное protobuf-сообщение CEconItemPreviewDataBlock. Декодирование целиком локальное: развернуть конверт, проверить CRC (или снять XOR-маску), разобрать protobuf и переинтерпретировать одно поле uint32 как IEEE-754 float. Ни входа в Steam, ни бот-аккаунта, ни лимитов, ни единого сетевого запроса. api.csfloat.com как путь резолва больше не существует — домен даже не резолвится, а репозиторий csfloat/inspect заархивирован 25 марта 2026 с пометкой, отправляющей пользователей к библиотеке-сериализатору.
Оговорка, и она серьёзная: классические ссылки S/A/D по-прежнему не декодируются офлайн. Это указатели, а не payload, и для их резолва всё так же нужно подключение к game coordinator. Где именно проходит эта граница — ниже.
Что именно изменилось в марте 2026
До изменения inspect-ссылка выглядела как набор ключей к базе:
steam://rungame/730/76561202255233023/+csgo_econ_action_preview S76561198084749846A698323590D7935523998312483177
S — SteamID владельца (либо M, идентификатор лота на площадке), A — asset id, D — детерминированное «d-значение», доказательство. Ни один из этих байтов не говорит о float. Чтобы его узнать, нужно было залогинить Steam-бота в game coordinator CS2, отправить preview-запрос, дождаться ответа и надеяться, что GC жив. Ровно поэтому float-API вообще существовали: это были пулы бот-аккаунтов, каждый с потолком примерно в один запрос в секунду.
Новый формат переворачивает модель. Valve теперь отдаёт ссылки, хвост которых — чистый шестнадцатеричный код, и этот хвост и есть предмет:
steam://rungame/730/76561202255233023/+csgo_econ_action_preview 0010DAB7CD80A4011807209A02...
Всё, что нужно для превью — paint index, paint seed, износ, стикеры, чармы — едет в самом URL. Game coordinator из цепочки для таких ссылок исключён полностью.
Отличить одно от другого элементарно. Возьмите хвост после csgo_econ_action_preview. Если он целиком состоит из [0-9A-Fa-f] и имеет чётную длину — ссылка самокодирующаяся, декодируется локально. Если внутри есть буквы S, M, A или D как маркеры параметров — это классическая ссылка, и ей нужен GC. Эта единственная проверка и есть точка ветвления любого современного резолвера.
Анатомия самокодирующейся ссылки
Hex разворачивается в буфер байтов: небольшой конверт вокруг protobuf-сообщения.
| Часть | Байты | Что это |
|---|---|---|
| Ключевой байт | buf[0] | 0x00 = payload без маски. Любое другое значение = маска, и это её XOR-ключ |
| Payload | buf[1:-4] | Сериализованное protobuf-сообщение CEconItemPreviewDataBlock |
| Контрольная сумма | buf[-4:] | Big-endian uint32, производная от CRC32, проверяется на payload без маски |
В природе встречаются два варианта:
- Без маски — первый байт
0x00. Четыре хвостовых байта это контрольная сумма, которую стоит проверить. Ссылки, сгенерированные сторонними инструментами, обычно именно такие. - С маской — первый байт ненулевой. Каждый байт буфера, включая первый, проксорен с этим первым байтом. Проксорьте весь буфер значением
buf[0]— и получите форму без маски (ключевой байт, проксоренный сам с собой, становится0x00; это и есть инвариант, по которому вы подтверждаете, что сделали всё верно). Ссылки, которые генерирует сама игра, обычно приходят с маской.
Контрольная сумма — не обычный CRC32, а CRC32 со свёрткой по длине сверху. Референсная реализация в @csfloat/cs-inspect-serializer выглядит так:
export const getChecksum = (payload: Uint8Array): number => {
const bufferPayload = Buffer.concat([Uint8Array.from([0]), payload]);
const crc = CRC32.buf(bufferPayload);
const x_crc = (crc & 0xffff) ^ (payload.byteLength * crc);
return (x_crc & 0xffffffff) >>> 0;
};
Обратите внимание на две детали, которые легко пропустить: CRC считается по payload с предпосланным 0x00, а результат сворачивается как (crc & 0xffff) ^ (length * crc) и только потом обрезается до 32 бит.
Карта полей protobuf
Payload — это CEconItemPreviewDataBlock, описанный в cstrike15_gcmessages.proto и публично отслеживаемый репозиторием GameTracking-CS2 от SteamDB. Значение имеют номера полей: сообщение можно разобрать минимальным ридером, ни разу не тронув компилятор .proto.
| # | Поле | Тип | Примечания |
|---|---|---|---|
| 1 | accountid | uint32 | Account id владельца. В самокодирующихся ссылках часто отсутствует |
| 2 | itemid | uint64 | Asset id |
| 3 | defindex | uint32 | Тип оружия (7 = AK-47) |
| 4 | paintindex | uint32 | Paint kit / id скина (282 = Redline) |
| 5 | rarity | uint32 | Уровень редкости |
| 6 | quality | uint32 | Уровень качества |
| 7 | paintwear | uint32 | Float в виде сырых битов IEEE-754 — см. ниже |
| 8 | paintseed | uint32 | Индекс паттерна |
| 9 | killeaterscoretype | uint32 | Наличие означает счётчик StatTrak / Souvenir |
| 10 | killeatervalue | uint32 | Число убийств |
| 11 | customname | string | Name tag |
| 12 | stickers | repeated Sticker | Наклеенные стикеры |
| 13 | inventory | uint32 | Слот / корзина инвентаря |
| 14 | origin | uint32 | Каким путём предмет получен |
| 15 | questid | uint32 | |
| 16 | dropreason | uint32 | |
| 17 | musicindex | uint32 | Music kit |
| 18 | entindex | int32 | |
| 19 | petindex | uint32 | |
| 20 | keychains | repeated Sticker | Чармы — та же форма вложенного сообщения, что у стикеров |
| 21 | style | uint32 | |
| 22 | variations | repeated Sticker | |
| 23 | upgrade_level | uint32 |
Вложенное сообщение Sticker, переиспользуемое для стикеров, чармов и вариаций:
| # | Поле | Тип |
|---|---|---|
| 1 | slot | uint32 |
| 2 | sticker_id | uint32 |
| 3 | wear | float |
| 4 | scale | float |
| 5 | rotation | float |
| 6 | tint_id | uint32 |
| 7-9 | offset_x / offset_y / offset_z | float |
| 10 | pattern | uint32 |
| 11 | highlight_reel | uint32 |
| 12 | wrapped_sticker | uint32 |
paintwear — главная ловушка. Поле объявлено как uint32 и едет по проводу варинтом, но как число это целое бессмысленно: его 32 бита и есть float32. Биты нужно переинтерпретировать, а не приводить. В Python: struct.unpack("<f", struct.pack("<I", value))[0]. Приведение вместо переинтерпретации даёт значения в миллиардах и баг, который выглядит как повреждённые данные.
Разбор на практике: ссылка по байтам
Ниже полная самокодирующаяся ссылка. Каждое число получено реальным прогоном декодера, а не переписано из документации.
steam://rungame/730/76561202255233023/+csgo_econ_action_preview%200010DAB7CD80A4011807209A022805300438899F9BF3034095056205080010A82768838080800C7004E71B40F1
- Снять обёртку. URL-декодировать (
%20это пробел) и взять хвост послеcsgo_econ_action_preview. В нём 90 hex-символов, все[0-9A-F], длина чётная — самокодирование подтверждено. - Hex в байты. 90 символов превращаются в 45 байт.
- Прочитать ключевой байт.
buf[0]равен0x00, значит payload без маски. XOR не нужен. - Разделить конверт. Payload = байты с 1 по 40 (40 байт). Хвост = последние 4 байта,
E7 1B 40 F1. - Проверить контрольную сумму. CRC32 по
0x00плюс payload, свёрнутый по формуле выше, даёт0xE71B40F1— ровно совпадает с хвостом. Payload цел. - Разобрать protobuf. Идти парами тег/значение до конца буфера.
40 байт payload разбираются ровно в десять полей:
| Сырые байты | Поле | Wire | Значение |
|---|---|---|---|
10 DA B7 CD 80 A4 01 | 2 itemid | varint | 44024683482 |
18 07 | 3 defindex | varint | 7 (AK-47) |
20 9A 02 | 4 paintindex | varint | 282 (Redline) |
28 05 | 5 rarity | varint | 5 |
30 04 | 6 quality | varint | 4 |
38 89 9F 9B F3 03 | 7 paintwear | varint | 1046925193 -> 0.22540106 |
40 95 05 | 8 paintseed | varint | 661 |
62 05 08 00 10 A8 27 | 12 stickers | length-delimited | slot 0, sticker_id 5032 |
68 83 80 80 80 0C | 13 inventory | varint | 3221225475 |
70 04 | 14 origin | varint | 4 |
Прочитайте один тег руками, чтобы увидеть механику. Байт 0x20 это 32 в десятичной; protobuf-тег упаковывает номер поля в старшие биты, а wire type — в три младших: 32 >> 3 даёт 4 (поле 4, paintindex), а 32 & 7 даёт 0 (varint). Следующий варинт 9A 02 — это little-endian по основанию 128: (0x9A & 0x7F) | (0x02 << 7) = 26 | 256 = 282. Redline.
Самое интересное — поле 7. Варинт 89 9F 9B F3 03 даёт целое 1046925193. Как число это бессмыслица. Переинтерпретированное как биты float32, оно равно 0.22540105879306793 — AK-47 | Redline в Field-Tested. Это и есть float, полученный без единого сетевого запроса.
Тот же предмет, замаскированный ключом 0x9E, — это тот же самый буфер, побайтово проксоренный:
csgo_econ_action_preview 9E8E4429531E3A9F8699BE049CB69BAE9AA61701056D9DDE0B9BFC9B969E8E36B9F61D1E1E1E92EE9A7985DE6F
Проксорьте каждый байт значением 0x9E, первый байт станет 0x00, и дальше декодирование идёт точно так же — тот же float, тот же сид.
Рабочий декодер примерно в 60 строк
Только стандартная библиотека — ни компилятора protobuf, ни зависимостей. Обрабатывает оба варианта payload, с маской и без.
import re, struct, zlib, urllib.parse
LINK = re.compile(r"csgo_econ_action_preview[\s+]*([0-9A-Fa-f]+)\s*$")
def read_varint(b, i):
shift = val = 0
while True:
byte = b[i]; i += 1
val |= (byte & 0x7F) << shift
if not byte & 0x80:
return val, i
shift += 7
if shift > 70:
raise ValueError("varint too long")
def parse_pb(b):
out, i, n = {}, 0, len(b)
while i < n:
key, i = read_varint(b, i)
field, wt = key >> 3, key & 7
if wt == 0:
v, i = read_varint(b, i)
elif wt == 5:
v, i = b[i:i+4], i + 4
elif wt == 2:
ln, i = read_varint(b, i)
v, i = b[i:i+ln], i + ln
if len(v) < ln:
raise ValueError("truncated field")
else:
raise ValueError(f"unsupported wire type {wt}")
out.setdefault(field, []).append(v)
return out
def checksum(payload):
crc = zlib.crc32(b"\x00" + payload) & 0xFFFFFFFF
return ((crc & 0xFFFF) ^ (len(payload) * crc)) & 0xFFFFFFFF
def unwrap(buf):
if len(buf) < 5:
raise ValueError("payload too short")
if buf[0] != 0: # с маской: XOR каждого байта ключевым байтом
buf = bytes(x ^ buf[0] for x in buf)
else: # без маски: проверяем хвостовую контрольную сумму
if checksum(buf[1:-4]) != struct.unpack(">I", buf[-4:])[0]:
raise ValueError("checksum mismatch")
return buf[1:-4]
def sticker(raw):
pb = parse_pb(raw)
f = lambda k: struct.unpack("<f", pb[k][-1])[0] if k in pb else None
return {"slot": pb.get(1, [None])[-1], "sticker_id": pb.get(2, [None])[-1],
"wear": f(3), "rotation": f(5), "pattern": pb.get(10, [None])[-1]}
def decode(link):
m = LINK.search(urllib.parse.unquote(link.strip()))
if not m:
raise ValueError("not a self-encoding link (classic S/A/D links need the GC)")
pb = parse_pb(unwrap(bytes.fromhex(m.group(1))))
g = lambda k: pb[k][-1] if k in pb else None
wear = g(7)
return {
"itemid": g(2),
"defindex": g(3),
"paint_index": g(4),
"rarity": g(5),
"quality": g(6),
"float": struct.unpack("<f", struct.pack("<I", wear))[0] if wear is not None else None,
"paint_seed": g(8),
"killeater_type": g(9), # присутствует => счётчик StatTrak / Souvenir
"kill_count": g(10),
"custom_name": g(11).decode("utf-8") if g(11) else None,
"stickers": [sticker(x) for x in pb.get(12, [])],
"keychains": [sticker(x) for x in pb.get(20, [])],
}
if __name__ == "__main__":
link = ("steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20"
"0010DAB7CD80A4011807209A022805300438899F9BF303409505"
"6205080010A82768838080800C7004E71B40F1")
for k, v in decode(link).items():
print(f"{k:15} {v}")
Вывод:
itemid 44024683482
defindex 7
paint_index 282
rarity 5
quality 4
float 0.22540105879306793
paint_seed 661
killeater_type None
kill_count None
custom_name None
stickers [{'slot': 0, 'sticker_id': 5032, ...}]
keychains []
Валидируйте, прежде чем доверять. Произвольный hex может разобраться как синтаксически корректный protobuf и вернуть вам мусор. Требуйте хотя бы одно опознавательное поле (itemid, defindex, paintindex, paintseed или непустой массив стикеров/чармов), прежде чем отдавать результат. Для payload с маской референсная реализация ещё строже: она требует, чтобы присутствовали itemid, defindex, paintindex, inventory и origin одновременно. Всё, что эти проверки не проходит, должно бросать исключение, а не возвращать пустые значения.
Чего локально всё равно не получить
Честность насчёт границы важнее самого декодирования, потому что продукт, построенный на неверном допущении в этом месте, обходится дорого.
Классические ссылки S/A/D и M/A/D. В них нет вообще никаких данных предмета. Для резолва нужен Steam-аккаунт, залогиненный в game coordinator CS2. Если на вход к вам попадают исторические ссылки, лоты Steam Community Market или что угодно, собранное из старых источников, вам всё ещё нужна GC-инфраструктура — либо готовность признать, что такие входные данные не резолвятся. Все офлайн-библиотеки говорят одно и то же; в документации Helyux/cs2inspect прямо сказано, что легаси-указатели лежат за пределами возможностей офлайн-библиотеки.
Актуальное владение и торгуемость. В payload есть поле accountid, но в самокодирующихся ссылках оно часто отсутствует, а если присутствует — это снимок на момент генерации ссылки, а не текущий факт. Нынешнего владельца, срок трейд-локa, статус лота и цену ссылка не содержит и содержать не будет.
Читаемые названия. Ссылка даёт defindex 7 и paintindex 282, а не «AK-47 | Redline». Превращение id в названия требует схемы предметов игры (items_game.txt или зеркала дампа схемы). То же и со стикером id 5032 — это поиск по справочнику, а не декодирование.
Названия износа. «Field-Tested» в payload нет. Оно выводится из float по границам износа конкретного скина, а эти min/max тоже живут в схеме, а не в ссылке.
Всё, что выводится из сида. Процент fade, тиры blue gem, фазы доплера, паттерны Case Hardened — ссылка даёт только сырое целое paintseed. Превращение сида в «97.4% fade» или «blue gem первого тира» это отдельный расчёт или таблица соответствий на вашей стороне.
Самокодирующаяся ссылка — это снимок, а не живая запись. Ничто не мешает человеку собрать валидную ссылку, описывающую предмет, которого не существует или которым он не владеет. CRC защищает от повреждения, а не от подделки: валидную контрольную сумму может посчитать кто угодно. Если ваш пайплайн принимает финансовые решения, считайте декодированную ссылку непроверенным утверждением о предмете, пока не подтвердите актив независимо.
Типичные ошибки
- Приведение
paintwearвместо переинтерпретации. Баг номер один.float(1046925193)это не0.2254. Нужен битовый шаблон. - Допущение, что все ссылки самокодирующиеся. Ветвитесь по формату хвоста. Резолвер, который бросает исключение на классических ссылках, работает правильно; тот, что молча возвращает пустые значения, — нет.
- Пропуск проверки на нечётную длину. Обрезанный hex вызывает невнятную ошибку где-то в глубине
bytes.fromhex. Проверяйте чётность длины hex заранее. - Забытый ведущий
0x00в контрольной сумме. CRC по «голому» payload даёт расхождение на каждой валидной ссылке и отправляет вас искать несуществующий баг. - Проверка контрольной суммы на payload с маской. Сначала снимите маску; не сверяйте CRC с замаскированными байтами.
- Чтение только первого вхождения повторяемого поля. Protobuf допускает повторяющиеся теги; стикеры и чармы реально повторяются. Собирайте их в список.
- Отсутствие URL-декодирования. Ссылки, скопированные из браузеров и мессенджеров, приходят с
%20вместо пробела, а иногда и с переводом строки на конце. - Успешный парсинг вместо валидации. Случайный hex довольно часто разбирается без ошибок. Применяйте проверки на наличие полей.
- Захардкоженный маппинг названий предметов. Paint index-ы накапливаются с каждым новым кейсом. Обновляйте схему, а не вшивайте таблицу.
Что теперь с float-API
Практический итог: резолв float превратился из инфраструктурной задачи в задачу парсинга — по крайней мере для ссылок, сгенерированных после марта 2026. То, что раньше означало бот-аккаунт, сессию с GC, ретраи и очередь, теперь занимает микросекунды локального CPU. Лимиты исчезают. Простои исчезают. Хорошо задокументированная ненадёжность GC перестаёт быть вашей проблемой — для таких ссылок.
Остаётся неромантичное, но реальное расщепление в любом продакшн-резолвере: быстрый локальный путь для hex-хвостов и медленный, хрупкий, зависящий от аккаунтов путь для легаси-указателей. Мы держим ровно такую развилку, и честный итог такой: локальный путь обслуживает подавляющее большинство текущего трафика, а путь через GC отказывается умирать окончательно.
csgo_econ_action_preview — чистый шестнадцатеричный код, float закодирован внутри самой ссылки и декодируется локально, без обращения к сети. Классическим ссылкам S/A/D по-прежнему нужно подключение к game coordinator.0x00 и заканчиваются 4-байтовой контрольной суммой, которую можно проверить. Буферы с маской начинаются с ненулевого ключевого байта, и каждый байт проксорен этим ключом. Проксорьте весь буфер его первым байтом, чтобы вернуть форму без маски.paintwear вместо переинтерпретации его битов. Поле объявлено как uint32, но эти 32 бита — представление IEEE-754 float32. Используйте struct.unpack("<f", struct.pack("<I", value))[0].defindex и paintindex. Чтобы сопоставить их с «AK-47 | Redline», нужна схема предметов игры, которую вы поддерживаете отдельно от декодера.Декодирование — простая половина. Настоящая работа начинается там, где paintseed 661 превращается в вердикт по паттерну, а float — в кондицию и справедливую цену. Ровно для этого и существуют наши бесплатные инструменты.