Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ vendor/
.phpunit.result.cache
.DS_Store
.idea/
.vscode/
37 changes: 33 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,13 @@ Gen codes are space-separated command strings used on CS2 community servers to s

Format:
```
!gen {defindex} {paintindex} {paintseed} {paintwear} [{s0_id} {s0_wear} ... {s4_id} {s4_wear}] [{kc_id} {kc_wear} ...]
!gen {defindex} {paintindex} {paintseed} {paintwear} [{s0_id} {s0_wear} ... {s4_id} {s4_wear}] [{kc_id} {kc_wear} ...] {scoreType} {kills} [sd {n} ...] [kd {n} ...]
```

- Stickers are always represented as 5 slot pairs (padded with `0 0` for empty slots)
- Keychains are appended without padding, only for present slots
- StatTrak is appended as a mandatory numeric suffix: `{scoreType} {kills}` (`0 0` means no StatTrak value)
- Optional `sd`/`kd` blocks preserve exact placement (slot, scale, rotation, offsets, pattern, paintKit) for stickers/keychains
- Float values have trailing zeros stripped (max 8 decimal places); `0.0` becomes `"0"`

### Generate a Steam inspect URL from parameters
Expand Down Expand Up @@ -123,17 +125,44 @@ $item = new ItemPreviewData(
);

$code = GenCode::toGenCode($item);
// "!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0"
// "!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0 0 0"

$code = GenCode::toGenCode($item, '!g'); // custom prefix
```

Exact placement example (includes `sd` block):

```php
$item = new ItemPreviewData(
defindex: 7,
paintindex: 474,
paintseed: 306,
paintwear: 0.22540508,
stickers: [
new Sticker(
slot: 1,
stickerId: 7436,
wear: 0.08,
scale: 1.2,
rotation: 123.45,
offsetX: 0.11,
offsetY: -0.22,
offsetZ: 0.33,
pattern: 9,
),
],
);

$code = GenCode::toGenCode($item, '');
// ... 0 0 sd 1 1 7436 0.08 1.2 123.45 0.11 -0.22 0.33 9 0
```

### Parse a gen code string

```php
use VlyDev\Steam\GenCode;

$item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0');
$item = GenCode::parseGenCode('!gen 7 474 306 0.22540508 0 0 0 0 7203 0 0 0 0 0 0 0');
echo $item->defindex; // 7
echo $item->paintindex; // 474
echo $item->paintseed; // 306
Expand All @@ -146,7 +175,7 @@ $item2 = GenCode::parseGenCode('7 474 306 0.22540508'); // prefix is optional

```php
$code = GenCode::genCodeFromLink('steam://rungame/730/76561202255233023/+csgo_econ_action_preview%20001A...');
// "!gen 7 474 306 0.22540508"
// "!gen 7 474 306 0.22540508 0 0 0 0 0 0 0 0 0 0 0 0"
```

---
Expand Down
182 changes: 180 additions & 2 deletions src/GenCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@
*
* Gen codes are space-separated command strings used on community servers:
* !gen {defindex} {paintindex} {paintseed} {paintwear}
* !gen {defindex} {paintindex} {paintseed} {paintwear} {s0_id} {s0_wear} ... {s4_id} {s4_wear} [{kc_id} {kc_wear} ...]
* !gen {defindex} {paintindex} {paintseed} {paintwear} {s0_id} {s0_wear} ... {s4_id} {s4_wear} [{kc_id} {kc_wear} ...] {scoreType} {kills} [sd {n} ...] [kd {n} ...]
*
* Stickers are always padded to 5 slot pairs. Keychains follow without padding.
* StatTrak is appended as two trailing numeric tokens: {scoreType} {kills}.
* Optional sd/kd blocks preserve exact placement/rotation data for round-trips.
*/
final class GenCode
{
Expand Down Expand Up @@ -68,6 +70,108 @@ private static function serializeStickerPairs(array $stickers, ?int $padTo): arr
return $result;
}

/**
* Serialize exact sticker/charm placement records.
*
* Record layout:
* slot id wear scale rotation offsetX offsetY offsetZ pattern paintKit
*
* @param Sticker[] $stickers
* @return string[]
*/
private static function serializePlacementDetails(array $stickers, string $marker): array
{
$filtered = array_values(array_filter($stickers, fn(Sticker $s) => $s->stickerId !== 0));
if ($filtered === []) {
return [];
}

usort($filtered, fn(Sticker $a, Sticker $b) => $a->slot <=> $b->slot);

$result = [$marker, (string) count($filtered)];
foreach ($filtered as $s) {
$result[] = (string) $s->slot;
$result[] = (string) $s->stickerId;
$result[] = self::formatFloat($s->wear ?? 0.0);
$result[] = self::formatFloat($s->scale ?? 0.0);
$result[] = self::formatFloat($s->rotation ?? 0.0);
$result[] = self::formatFloat($s->offsetX ?? 0.0);
$result[] = self::formatFloat($s->offsetY ?? 0.0);
$result[] = self::formatFloat($s->offsetZ ?? 0.0);
$result[] = (string) $s->pattern;
$result[] = (string) ($s->paintKit ?? 0);
}

return $result;
}

/**
* Decide whether exact sticker details should be emitted.
*
* @param Sticker[] $stickers
*/
private static function needsStickerDetailBlock(array $stickers): bool
{
foreach ($stickers as $s) {
if (
$s->stickerId !== 0
&& (
$s->scale !== null
|| $s->rotation !== null
|| $s->offsetX !== null
|| $s->offsetY !== null
|| $s->offsetZ !== null
|| $s->pattern !== 0
|| $s->paintKit !== null
)
) {
return true;
}
}

return false;
}

/**
* @param string[] $tokens
* @param int $start
* @return array{list<Sticker>, int}
*/
private static function parsePlacementDetails(array $tokens, int $start): array
{
$parsed = [];
$count = count($tokens);

if ($start + 1 >= $count) {
throw new \InvalidArgumentException('Malformed detail block: missing record count.');
}

$records = (int) $tokens[$start + 1];
$i = $start + 2;

for ($r = 0; $r < $records; $r++) {
if ($i + 9 >= $count) {
throw new \InvalidArgumentException('Malformed detail block: truncated record.');
}

$parsed[] = new Sticker(
slot: (int) $tokens[$i],
stickerId: (int) $tokens[$i + 1],
wear: (float) $tokens[$i + 2],
scale: (float) $tokens[$i + 3],
rotation: (float) $tokens[$i + 4],
offsetX: (float) $tokens[$i + 5],
offsetY: (float) $tokens[$i + 6],
offsetZ: (float) $tokens[$i + 7],
pattern: (int) $tokens[$i + 8],
paintKit: (int) $tokens[$i + 9],
);
$i += 10;
}

return [$parsed, $i];
}

/**
* Convert an ItemPreviewData to a gen code string.
*
Expand All @@ -85,6 +189,16 @@ public static function toGenCode(ItemPreviewData $item, string $prefix = '!gen')

array_push($parts, ...self::serializeStickerPairs($item->stickers, 5));
array_push($parts, ...self::serializeStickerPairs($item->keychains, null));
array_push($parts, (string) $item->killeaterscoretype, (string) $item->killeatervalue);

if (self::needsStickerDetailBlock($item->stickers)) {
array_push($parts, ...self::serializePlacementDetails($item->stickers, 'sd'));
}

// Always include keychain details so charm slot and placement are never lost.
if ($item->keychains !== []) {
array_push($parts, ...self::serializePlacementDetails($item->keychains, 'kd'));
}

$payload = implode(' ', $parts);
return $prefix !== '' ? "{$prefix} {$payload}" : $payload;
Expand Down Expand Up @@ -145,7 +259,7 @@ public static function genCodeFromLink(string $hexOrUrl, string $prefix = '!gen'
*
* Accepts codes like:
* "!gen 7 474 306 0.22540508"
* "7 941 2 0.22540508 0 0 0 0 7203 0 0 0 0 0 36 0"
* "7 941 2 0.22540508 0 0 0 0 7203 0 0 0 0 0 36 0 0 0"
*
* @throws \InvalidArgumentException If the code has fewer than 4 tokens.
*/
Expand All @@ -172,6 +286,60 @@ public static function parseGenCode(string $genCode): ItemPreviewData
$paintSeed = (int) $tokens[2];
$paintWear = (float) $tokens[3];
$rest = array_slice($tokens, 4);
$killeaterScoreType = 0;
$killeaterValue = 0;
$detailedStickers = null;
$detailedKeychains = null;

// Optional detailed blocks: sd/kd
$detailStart = null;
foreach ($rest as $idx => $tok) {
if ($tok === 'sd' || $tok === 'kd') {
$detailStart = $idx;
break;
}
}

if ($detailStart !== null) {
$detailTokens = array_slice($rest, $detailStart);
$rest = array_slice($rest, 0, $detailStart);

$i = 0;
while ($i < count($detailTokens)) {
$marker = $detailTokens[$i] ?? null;
if ($marker !== 'sd' && $marker !== 'kd') {
throw new \InvalidArgumentException('Malformed detail blocks: unknown marker token.');
}

[$parsed, $nextIndex] = self::parsePlacementDetails($detailTokens, $i);
if ($marker === 'sd') {
$detailedStickers = $parsed;
} else {
$detailedKeychains = $parsed;
}

$i = $nextIndex;
}
}

// Compatibility 1: marker-based suffix: "st <scoreType> <kills>"
if (count($rest) >= 3) {
$stPos = array_search('st', $rest, true);
if ($stPos !== false && $stPos + 2 < count($rest)) {
$killeaterScoreType = (int) $rest[$stPos + 1];
$killeaterValue = (int) $rest[$stPos + 2];
$rest = array_slice($rest, 0, $stPos);
}
}

// Compatibility 2 (current format): trailing numeric suffix: "... <scoreType> <kills>"
// If no marker is present and at least two tokens remain, consume the last two tokens.
if (count($rest) >= 2) {
$last = count($rest) - 1;
$killeaterScoreType = (int) $rest[$last - 1];
$killeaterValue = (int) $rest[$last];
$rest = array_slice($rest, 0, $last - 1);
}

$stickers = [];
$keychains = [];
Expand All @@ -196,11 +364,21 @@ public static function parseGenCode(string $genCode): ItemPreviewData
}
}

if ($detailedStickers !== null) {
$stickers = $detailedStickers;
}

if ($detailedKeychains !== null) {
$keychains = $detailedKeychains;
}

return new ItemPreviewData(
defindex: $defIndex,
paintindex: $paintIndex,
paintseed: $paintSeed,
paintwear: $paintWear,
killeaterscoretype: $killeaterScoreType,
killeatervalue: $killeaterValue,
stickers: $stickers,
keychains: $keychains,
);
Expand Down
Loading