Skip to content

Commit 8a0ee81

Browse files
committed
add steam inspect link support (!g / !i / !inspect)
1 parent fe82865 commit 8a0ee81

4 files changed

Lines changed: 359 additions & 28 deletions

File tree

src/Commands/CmdGen.cs

Lines changed: 62 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ private void CmdGen(CCSPlayerController? player, CommandInfo info)
1414

1515
if (info.ArgCount < 2)
1616
{
17-
player.PrintToChat(" Usage: !g <gencode> (gencode from cs2inspects.com)");
17+
player.PrintToChat(" Usage: !g <gencode or inspect link>");
1818
return;
1919
}
2020

@@ -27,6 +27,42 @@ private void CmdGen(CCSPlayerController? player, CommandInfo info)
2727

2828
private async Task FetchAndGiveAsync(string gencode, int? userId, ulong steamId)
2929
{
30+
if (InspectLinkParser.TryParse(gencode, out var inspectData, out var parseError))
31+
{
32+
var defIndex = (ushort)inspectData.DefIndex;
33+
var paintKit = (int)inspectData.PaintIndex;
34+
var seed = (int)inspectData.PaintSeed;
35+
var wear = inspectData.Wear;
36+
37+
var rawStickers = new StickerSlot[inspectData.Stickers.Length];
38+
for (int i = 0; i < inspectData.Stickers.Length; i++)
39+
{
40+
var s = inspectData.Stickers[i];
41+
rawStickers[i] = new StickerSlot((int)s.Slot, (int)s.Id, s.Wear, s.OffsetX, s.OffsetY, s.Rotation);
42+
}
43+
44+
var kc = inspectData.Keychains.Length > 0 ? inspectData.Keychains[0] : default;
45+
46+
GiveItem(defIndex, paintKit, seed, wear,
47+
DeduplicateStickerSlots(rawStickers),
48+
(int)kc.Id, (int)kc.Pattern, kc.OffsetX, kc.OffsetY, kc.OffsetZ,
49+
inspectData.Quality == 9,
50+
inspectData.Quality == 9 ? (int)inspectData.KillEaterValue : 0,
51+
inspectData.CustomName,
52+
userId, steamId);
53+
return;
54+
}
55+
56+
if (parseError != null)
57+
{
58+
Server.NextFrame(() =>
59+
{
60+
var p = Utilities.GetPlayerFromUserid(userId ?? 0);
61+
p?.PrintToChat($" {C.DarkRed}{C.Default}{parseError}");
62+
});
63+
return;
64+
}
65+
3066
var (apiResponse, lastError) = await FetchGenCodeAsync(gencode);
3167
var detail = apiResponse?.GenCodeDetail;
3268

@@ -41,7 +77,7 @@ private async Task FetchAndGiveAsync(string gencode, int? userId, ulong steamId)
4177
return;
4278
}
4379

44-
if (!ushort.TryParse(detail.ItemId, out var defIndex))
80+
if (!ushort.TryParse(detail.ItemId, out var apiDefIndex))
4581
{
4682
Server.NextFrame(() =>
4783
{
@@ -51,33 +87,34 @@ private async Task FetchAndGiveAsync(string gencode, int? userId, ulong steamId)
5187
return;
5288
}
5389

54-
int.TryParse(detail.SkinId, out var paintKit);
55-
int.TryParse(detail.PatternId, out var seed);
90+
int.TryParse(detail.SkinId, out var apiPaintKit);
91+
int.TryParse(detail.PatternId, out var apiSeed);
5692
float.TryParse(detail.FloatValue, NumberStyles.Float,
57-
CultureInfo.InvariantCulture, out var wear);
58-
59-
var stickers = DeduplicateStickerSlots(new[]
60-
{
61-
new StickerSlot(detail.Sticker1Slot, detail.Sticker1Id, detail.Sticker1Value, detail.Sticker1X, detail.Sticker1Y, detail.Sticker1R),
62-
new StickerSlot(detail.Sticker2Slot, detail.Sticker2Id, detail.Sticker2Value, detail.Sticker2X, detail.Sticker2Y, detail.Sticker2R),
63-
new StickerSlot(detail.Sticker3Slot, detail.Sticker3Id, detail.Sticker3Value, detail.Sticker3X, detail.Sticker3Y, detail.Sticker3R),
64-
new StickerSlot(detail.Sticker4Slot, detail.Sticker4Id, detail.Sticker4Value, detail.Sticker4X, detail.Sticker4Y, detail.Sticker4R),
65-
new StickerSlot(detail.Sticker5Slot, detail.Sticker5Id, detail.Sticker5Value, detail.Sticker5X, detail.Sticker5Y, detail.Sticker5R),
66-
});
93+
CultureInfo.InvariantCulture, out var apiWear);
6794

68-
var charmId = detail.KeyChainId;
69-
var charmSeed = detail.KeyChainPattern;
70-
var charmX = detail.KeyChainX;
71-
var charmY = detail.KeyChainY;
72-
var charmZ = detail.KeyChainZ;
73-
var statTrakEnabled = detail.StatTrakEnabled == "1";
74-
var statTrakValue = detail.StatTrakValue;
75-
var nameTag = detail.NameTag;
95+
GiveItem(apiDefIndex, apiPaintKit, apiSeed, apiWear,
96+
DeduplicateStickerSlots(new[]
97+
{
98+
new StickerSlot(detail.Sticker1Slot, detail.Sticker1Id, detail.Sticker1Value, detail.Sticker1X, detail.Sticker1Y, detail.Sticker1R),
99+
new StickerSlot(detail.Sticker2Slot, detail.Sticker2Id, detail.Sticker2Value, detail.Sticker2X, detail.Sticker2Y, detail.Sticker2R),
100+
new StickerSlot(detail.Sticker3Slot, detail.Sticker3Id, detail.Sticker3Value, detail.Sticker3X, detail.Sticker3Y, detail.Sticker3R),
101+
new StickerSlot(detail.Sticker4Slot, detail.Sticker4Id, detail.Sticker4Value, detail.Sticker4X, detail.Sticker4Y, detail.Sticker4R),
102+
new StickerSlot(detail.Sticker5Slot, detail.Sticker5Id, detail.Sticker5Value, detail.Sticker5X, detail.Sticker5Y, detail.Sticker5R),
103+
}),
104+
detail.KeyChainId, detail.KeyChainPattern, detail.KeyChainX, detail.KeyChainY, detail.KeyChainZ,
105+
detail.StatTrakEnabled == "1", detail.StatTrakValue, detail.NameTag,
106+
userId, steamId);
107+
}
76108

109+
private void GiveItem(
110+
ushort defIndex, int paintKit, int seed, float wear, StickerSlot[] stickers,
111+
int charmId, int charmSeed, float charmX, float charmY, float charmZ,
112+
bool statTrakEnabled, int statTrakValue, string nameTag,
113+
int? userId, ulong steamId)
114+
{
77115
if (IsGloveDefIndex(defIndex))
78116
{
79-
var pending = new PendingSkin(
80-
"", paintKit, seed, wear, new StickerSlot[5]);
117+
var pending = new PendingSkin("", paintKit, seed, wear, new StickerSlot[5]);
81118
Server.NextFrame(() =>
82119
{
83120
var p = Utilities.GetPlayerFromUserid(userId ?? 0);
@@ -106,7 +143,7 @@ private async Task FetchAndGiveAsync(string gencode, int? userId, ulong steamId)
106143
Server.NextFrame(() =>
107144
{
108145
var p = Utilities.GetPlayerFromUserid(userId ?? 0);
109-
p?.PrintToChat($" {C.DarkRed}{C.Default}Unsupported item (defindex {C.Green}{detail.ItemId}{C.Default}).");
146+
p?.PrintToChat($" {C.DarkRed}{C.Default}Unsupported item (defindex {C.Green}{defIndex}{C.Default}).");
110147
});
111148
return;
112149
}

src/Helpers/InspectLinkParser.cs

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
using System.Text;
2+
3+
namespace OpenGen;
4+
5+
internal static class InspectLinkParser
6+
{
7+
private const string ConsolePrefix = "csgo_econ_action_preview ";
8+
private const string SteamUrlPrefix = "steam://rungame/730/";
9+
private const string SteamActionFragment = "+csgo_econ_action_preview ";
10+
11+
public static bool TryParse(string input, out ParsedInspectData data, out string? error)
12+
{
13+
data = default;
14+
error = null;
15+
16+
if (!TryExtractHex(input.Trim(), out var hex, out error))
17+
return false;
18+
19+
if (error != null)
20+
return false;
21+
22+
byte[] rawBytes;
23+
try
24+
{
25+
rawBytes = Convert.FromHexString(hex);
26+
}
27+
catch (FormatException)
28+
{
29+
error = "Invalid inspect link: malformed hex string.";
30+
return false;
31+
}
32+
33+
try
34+
{
35+
var proto = UnframeBytes(rawBytes);
36+
data = DecodeProto(proto);
37+
return true;
38+
}
39+
catch
40+
{
41+
error = "Invalid inspect link: could not decode item data.";
42+
return false;
43+
}
44+
}
45+
46+
private static bool TryExtractHex(string input, out string hex, out string? error)
47+
{
48+
hex = "";
49+
error = null;
50+
51+
if (input.StartsWith(ConsolePrefix, StringComparison.OrdinalIgnoreCase))
52+
{
53+
var candidate = input[ConsolePrefix.Length..].Trim();
54+
if (IsUnmaskedFormat(candidate))
55+
{
56+
error = "Unmasked inspect links (S/A/D format) are not supported. Use a gencode from cs2inspects.com.";
57+
return false;
58+
}
59+
hex = candidate;
60+
return ValidateHexString(hex, out error);
61+
}
62+
63+
if (input.StartsWith(SteamUrlPrefix, StringComparison.OrdinalIgnoreCase))
64+
{
65+
var idx = input.IndexOf(SteamActionFragment, StringComparison.OrdinalIgnoreCase);
66+
if (idx < 0) return false;
67+
var candidate = input[(idx + SteamActionFragment.Length)..].Trim();
68+
if (IsUnmaskedFormat(candidate))
69+
{
70+
error = "Unmasked inspect links (S/A/D format) are not supported. Use a gencode from cs2inspects.com.";
71+
return false;
72+
}
73+
hex = candidate;
74+
return ValidateHexString(hex, out error);
75+
}
76+
77+
if (IsUnmaskedFormat(input))
78+
{
79+
error = "Unmasked inspect links (S/A/D format) are not supported. Use a gencode from cs2inspects.com.";
80+
return false;
81+
}
82+
83+
if (IsRawHex(input))
84+
{
85+
hex = input;
86+
return ValidateHexString(hex, out error);
87+
}
88+
89+
return false;
90+
}
91+
92+
private static bool IsUnmaskedFormat(string s) =>
93+
s.Length > 2 && s[0] == 'S' && char.IsDigit(s[1]);
94+
95+
private static bool IsRawHex(string s) =>
96+
s.Length >= 16 &&
97+
s.Any(c => c is (>= 'A' and <= 'F') or (>= 'a' and <= 'f')) &&
98+
s.All(c => c is (>= '0' and <= '9') or (>= 'A' and <= 'F') or (>= 'a' and <= 'f'));
99+
100+
private static bool ValidateHexString(string hex, out string? error)
101+
{
102+
error = null;
103+
if (hex.Length % 2 != 0)
104+
{
105+
error = "Invalid inspect link: hex string has an odd number of characters.";
106+
return false;
107+
}
108+
if (hex.Length < 12)
109+
{
110+
error = "Invalid inspect link: data too short.";
111+
return false;
112+
}
113+
return true;
114+
}
115+
116+
private static ReadOnlySpan<byte> UnframeBytes(byte[] raw)
117+
{
118+
if (raw.Length < 6)
119+
throw new InvalidOperationException("Inspect data too short.");
120+
121+
var key = raw[0];
122+
if (key != 0x00)
123+
{
124+
for (int i = 0; i < raw.Length; i++)
125+
raw[i] ^= key;
126+
}
127+
128+
return raw.AsSpan(1, raw.Length - 5);
129+
}
130+
131+
private static ParsedInspectData DecodeProto(ReadOnlySpan<byte> bytes)
132+
{
133+
int pos = 0;
134+
uint defIndex = 0, paintIndex = 0, paintSeed = 0, paintwear = 0;
135+
uint quality = 0, killEaterValue = 0;
136+
string customName = "";
137+
var stickers = new List<DecodedSubItem>();
138+
var keychains = new List<DecodedSubItem>();
139+
140+
while (pos < bytes.Length)
141+
{
142+
var tag = (uint)ReadVarint(bytes, ref pos);
143+
var fieldNum = tag >> 3;
144+
var wireType = tag & 7;
145+
146+
switch (fieldNum)
147+
{
148+
case 3: defIndex = (uint)ReadVarint(bytes, ref pos); break;
149+
case 4: paintIndex = (uint)ReadVarint(bytes, ref pos); break;
150+
case 6: quality = (uint)ReadVarint(bytes, ref pos); break;
151+
case 7: paintwear = (uint)ReadVarint(bytes, ref pos); break;
152+
case 8: paintSeed = (uint)ReadVarint(bytes, ref pos); break;
153+
case 10: killEaterValue = (uint)ReadVarint(bytes, ref pos); break;
154+
case 11:
155+
{
156+
var sub = ReadLengthDelimited(bytes, ref pos);
157+
customName = Encoding.UTF8.GetString(sub);
158+
break;
159+
}
160+
case 12:
161+
{
162+
var sub = ReadLengthDelimited(bytes, ref pos);
163+
stickers.Add(DecodeSubItem(sub));
164+
break;
165+
}
166+
case 20:
167+
{
168+
var sub = ReadLengthDelimited(bytes, ref pos);
169+
keychains.Add(DecodeSubItem(sub));
170+
break;
171+
}
172+
default:
173+
SkipField(bytes, ref pos, wireType);
174+
break;
175+
}
176+
}
177+
178+
return new ParsedInspectData(
179+
defIndex, paintIndex, paintSeed,
180+
BitConverter.Int32BitsToSingle((int)paintwear),
181+
quality, killEaterValue, customName,
182+
stickers.ToArray(), keychains.ToArray());
183+
}
184+
185+
private static DecodedSubItem DecodeSubItem(ReadOnlySpan<byte> bytes)
186+
{
187+
int pos = 0;
188+
uint slot = 0, id = 0, pattern = 0;
189+
float wear = 0, offsetX = 0, offsetY = 0, offsetZ = 0, rotation = 0;
190+
191+
while (pos < bytes.Length)
192+
{
193+
var tag = (uint)ReadVarint(bytes, ref pos);
194+
var fieldNum = tag >> 3;
195+
var wireType = tag & 7;
196+
197+
switch (fieldNum)
198+
{
199+
case 1: slot = (uint)ReadVarint(bytes, ref pos); break;
200+
case 2: id = (uint)ReadVarint(bytes, ref pos); break;
201+
case 3: wear = ReadFloat32(bytes, ref pos); break;
202+
case 4: ReadFloat32(bytes, ref pos); break; // unused
203+
case 5: rotation = ReadFloat32(bytes, ref pos); break;
204+
case 6: ReadVarint(bytes, ref pos); break; // unused
205+
case 7: offsetX = ReadFloat32(bytes, ref pos); break;
206+
case 8: offsetY = ReadFloat32(bytes, ref pos); break;
207+
case 9: offsetZ = ReadFloat32(bytes, ref pos); break;
208+
case 10: pattern = (uint)ReadVarint(bytes, ref pos); break;
209+
default: SkipField(bytes, ref pos, wireType); break;
210+
}
211+
}
212+
213+
return new DecodedSubItem(slot, id, wear, offsetX, offsetY, offsetZ, rotation, pattern);
214+
}
215+
216+
private static ulong ReadVarint(ReadOnlySpan<byte> data, ref int pos)
217+
{
218+
ulong result = 0;
219+
int shift = 0;
220+
221+
while (pos < data.Length)
222+
{
223+
var b = data[pos++];
224+
result |= (ulong)(b & 0x7F) << shift;
225+
if ((b & 0x80) == 0) return result;
226+
shift += 7;
227+
if (shift >= 70) throw new InvalidOperationException("Varint too long.");
228+
}
229+
230+
throw new InvalidOperationException("Unexpected end of data reading varint.");
231+
}
232+
233+
private static float ReadFloat32(ReadOnlySpan<byte> data, ref int pos)
234+
{
235+
if (pos + 4 > data.Length)
236+
throw new InvalidOperationException("Unexpected end of data reading float.");
237+
var value = BitConverter.ToSingle(data.Slice(pos, 4));
238+
pos += 4;
239+
return value;
240+
}
241+
242+
private static ReadOnlySpan<byte> ReadLengthDelimited(ReadOnlySpan<byte> data, ref int pos)
243+
{
244+
var length = (int)ReadVarint(data, ref pos);
245+
if (pos + length > data.Length)
246+
throw new InvalidOperationException("Length-delimited field exceeds data bounds.");
247+
var slice = data.Slice(pos, length);
248+
pos += length;
249+
return slice;
250+
}
251+
252+
private static void SkipField(ReadOnlySpan<byte> data, ref int pos, uint wireType)
253+
{
254+
switch (wireType)
255+
{
256+
case 0: ReadVarint(data, ref pos); break;
257+
case 1: pos += 8; break;
258+
case 2:
259+
{
260+
var len = (int)ReadVarint(data, ref pos);
261+
pos += len;
262+
break;
263+
}
264+
case 5: pos += 4; break;
265+
default: throw new InvalidOperationException($"Unknown protobuf wire type {wireType}.");
266+
}
267+
}
268+
}

0 commit comments

Comments
 (0)