Skip to content
Merged
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
69 changes: 35 additions & 34 deletions doc/code_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,46 +141,45 @@ CS2 1.41.8.8 (30/09/2026). **32 bytes**, and the code is neither prefixed with `
CSvbPubOq37zTGqtsPTP5QTrp5CB4xFXiKRLfzJsm49ZRe
```

| Byte | Bits | Property | Encoding |
| ------- | ----- | --------------------------- | --------------------------- |
| 0 | 0-7 | checksum | `sum(bytes[1..]) % 256` |
| 1 | 0-7 | version | always `1`, per container |
| 2 - 3 | 0-15 | `screenHeight` | uint16 |
| 4 | 0-3 | `style` | 0 to 9 |
| | 4 | `followRecoil` | |
| | 5 | unused | held `outlineEnabled` in v3 |
| | 6 | `centerDotEnabled` | |
| | 7 | `tStyleEnabled` | |
| 5 | 0-7 | `red` | 0 to 255 |
| 6 | 0-7 | `green` | 0 to 255 |
| 7 | 0-7 | `blue` | 0 to 255 |
| 8 | 0-7 | `alpha` | 0 to 255 |
| 9 | 0-7 | `outlineRed` | 0 to 255 |
| 10 | 0-7 | `outlineGreen` | 0 to 255 |
| 11 | 0-7 | `outlineBlue` | 0 to 255 |
| 12 | 0-7 | `outlineAlpha` | 0 to 255 |
| 13 | 0-7 | `thickness` | 0 to 255, pixels |
| 14 | 0-7 | `outlineMode` | 0 none, 1 full, 2 half |
| 15 | 0-7 | `gap` | **signed**, pixels |
| 16 | 0-7 | `length` | 0 to 255, pixels |
| 17 | 0-7 | `dynamicSpreadLimit` | 0 to 255 |
| 18 - 21 | 0-6 | `splitDistance` | 0 to 127 |
| | 7-13 | `innerSplitAlpha` | 0.01 steps |
| | 14-20 | `outerSplitAlpha` | 0.01 steps, plus 0.3 |
| | 21-27 | `splitSizeRatio` | 0.01 steps |
| | 28 | `scopeDotUseCrosshairColor` | |
| | 29-31 | unused | |
| 22 | 0-7 | `scopeDotScale` | 0.01 steps, plus 0.1 |
| 23 - 31 | all | unused | |
| Byte | Bits | Property | Encoding |
| ------- | ----- | --------------------------- | ------------------------- |
| 0 | 0-7 | checksum | `sum(bytes[1..]) % 256` |
| 1 | 0-7 | version | always `1`, per container |
| 2 - 3 | 0-15 | `screenHeight` | uint16 |
| 4 | 0-4 | `style` | 0 to 9 |
| | 5 | `followRecoil` | |
| | 6 | `centerDotEnabled` | |
| | 7 | `tStyleEnabled` | |
| 5 | 0-7 | `red` | 0 to 255 |
| 6 | 0-7 | `green` | 0 to 255 |
| 7 | 0-7 | `blue` | 0 to 255 |
| 8 | 0-7 | `alpha` | 0 to 255 |
| 9 | 0-7 | `outlineRed` | 0 to 255 |
| 10 | 0-7 | `outlineGreen` | 0 to 255 |
| 11 | 0-7 | `outlineBlue` | 0 to 255 |
| 12 | 0-7 | `outlineAlpha` | 0 to 255 |
| 13 | 0-5 | `thickness` | 0 to 32, pixels |
| | 6-7 | `outlineMode` | 0 none, 1 full, 2 half |
| 14 - 15 | 0-15 | `gap` | **int16**, -3840 to 3840 |
| 16 | 0-7 | `length` | 0 to 255, pixels |
| 17 | 0-7 | `dynamicSpreadLimit` | 0 to 255 |
| 18 - 21 | 0-6 | `splitDistance` | 0 to 127 |
| | 7-13 | `innerSplitAlpha` | 0.01 steps |
| | 14-20 | `outerSplitAlpha` | 0.01 steps, plus 0.3 |
| | 21-27 | `splitSizeRatio` | 0.01 steps |
| | 28 | `scopeDotUseCrosshairColor` | |
| | 29-31 | unused | |
| 22 | 0-7 | `scopeDotScale` | 0.01 steps, plus 0.1 |
| 23 - 31 | all | unused | |

### What changed from the `legacy-v4`

- The outline color was added as a full RGBA quad in the bytes 9 to 12.
- The scope dot preferences were added: `scopeDotScale` in the byte 22 and `scopeDotUseCrosshairColor` at the bit 28 of the bit field, the slot `legacy-v4` used for `outlineMode`.
- `outlineMode` moved out of the bit field into its own byte 14.
- `thickness` moved out of the bit field into its own byte 13, widening it from 5 bits to 8.
- `style` widened from 4 to 5 bits, which pushed `followRecoil` from the bit 4 to the bit 5. The `legacy-v3` outline flag slot is gone.
- `thickness` and `outlineMode` moved out of the bit field into the byte 13, packed as 6 + 2 bits. `thickness` widened from 5 to 6 bits and CS2 clamps it to 32.
- The split alphas went from 0.05 to 0.01 steps, so they need 7 bits each instead of 5 and 4.
- `gap` became signed, which is what allows negative gaps on the classic dynamic style.
- `gap` became a signed 16-bit integer in the bytes 14-15, which is what allows negative gaps on the classic dynamic style. CS2 clamps it to -3840 to 3840.
- `screenHeight` moved from the bytes 14-15 to the bytes 2-3.
- The style `9`, Static Quad, was added.

Expand All @@ -195,6 +194,8 @@ CSvbPubOq37zTGqtsPTP5QTrp5CB4xFXiKRLfzJsm49ZRe
| 90 | 1 |
| 255 | 2 (clamped) |

`gap` is clamped to -3840 to 3840, the range of the `cl_crosshair_gap` ConVar. The settings UI slider only goes from -10 to 128, wider values can be set from the console and survive a round trip through a share code. `thickness` is clamped to 0 to 32, the range of `cl_crosshair_thickness`.

`outerSplitAlpha` keeps the `legacy-v3` convention: it ranges from 0.3 to 1 and the stored value is the number of 0.01 steps above 0.3, so `0.35` is stored as `5`.

The styles are, per the `cl_crosshairstyle` ConVar help text: 0 Dynamic Cross, 1 Dynamic Circle, 2 Dynamic Cross (Legacy), 3 Static Circle, 4 Static Cross, 5 Static Cross (Shot Feedback), 6 Dot Only, 7 Dynamic Quad, 8 Static Square, 9 Static Quad.
Expand Down
50 changes: 33 additions & 17 deletions src/crosshair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
DICTIONARY,
InvalidCrosshairShareCode,
sumArray,
uint8ToInt8,
} from './share-code.js';

/**
Expand Down Expand Up @@ -48,9 +47,9 @@ export interface CrosshairV1 extends CrosshairWithFormat<'cs2-v1'> {
outlineGreen: number; // 0 to 255
outlineBlue: number; // 0 to 255
outlineAlpha: number; // 0 to 255
gap: number; // -128 to 127, negative values are allowed since the 30/09/2026 update
gap: number; // -3840 to 3840, stored as a signed 16-bit integer
length: number; // 0 to 255
thickness: number; // 0 to 255
thickness: number; // 0 to 32
dynamicSpreadLimit: number; // 0 to 255
splitDistance: number; // 0 to 127
innerSplitAlpha: number; // 0 to 1, 0.01 steps
Expand Down Expand Up @@ -107,19 +106,34 @@ const SCOPE_DOT_SCALE_MIN_STEPS = 10; // 0.1 * 100 = 10
const SCOPE_DOT_SCALE_MIN = 0.1;
const SCOPE_DOT_SCALE_MAX = 2;

// CS2 sanitizes an imported code before applying it: it rejects a screen height of 0, raises it to at least 240
// and caps every other field to the range of its ConVar. Decoding mirrors it to return what the game applies.
const SCREEN_HEIGHT_MIN = 240;
const STYLE_MAX = 9;
const THICKNESS_MAX = 32;
const OUTLINE_MODE_MAX = 2;
const GAP_MIN = -3840;
const GAP_MAX = 3840;
const SPLIT_STEPS_MAX = 100; // 1 * 100
const OUTER_SPLIT_ALPHA_STEPS_MAX = 70; // (1 - 0.3) * 100

function decodeCrosshairV1(bytes: number[]): CrosshairV1 {
const screenHeight = bytes[2] | (bytes[3] << 8);
if (screenHeight === 0) {
throw new InvalidCrosshairShareCode();
}

// Bytes 18 to 21 are a little-endian bit field of four 7 bits values followed by the scope dot
// color flag at the bit 28 - bits 29 to 31 are unused.
const bits = bytes[18] | (bytes[19] << 8) | (bytes[20] << 16) | (bytes[21] << 24);

return {
format: 'cs2-v1',
style: bytes[4] & 0xf,
followRecoil: (bytes[4] & 0x10) === 0x10,
// The bit 5 of the byte 4 is unused, it held the outline flag of the legacy-v3 codes.
style: Math.min(bytes[4] & 0x1f, STYLE_MAX),
followRecoil: (bytes[4] & 0x20) === 0x20,
centerDotEnabled: (bytes[4] & 0x40) === 0x40,
tStyleEnabled: (bytes[4] & 0x80) === 0x80,
outlineMode: bytes[14],
outlineMode: Math.min(bytes[13] >>> 6, OUTLINE_MODE_MAX),
red: bytes[5],
green: bytes[6],
blue: bytes[7],
Expand All @@ -128,15 +142,16 @@ function decodeCrosshairV1(bytes: number[]): CrosshairV1 {
outlineGreen: bytes[10],
outlineBlue: bytes[11],
outlineAlpha: bytes[12],
gap: uint8ToInt8(bytes[15]),
gap: clamp(((bytes[14] | (bytes[15] << 8)) << 16) >> 16, GAP_MIN, GAP_MAX),
length: bytes[16],
thickness: bytes[13],
thickness: Math.min(bytes[13] & 0x3f, THICKNESS_MAX),
dynamicSpreadLimit: bytes[17],
splitDistance: bits & 0x7f,
innerSplitAlpha: ((bits >> 7) & 0x7f) / STEPS_PER_UNIT,
outerSplitAlpha: (((bits >> 14) & 0x7f) + OUTER_SPLIT_ALPHA_MIN_STEPS) / STEPS_PER_UNIT,
splitSizeRatio: ((bits >> 21) & 0x7f) / STEPS_PER_UNIT,
screenHeight: bytes[2] | (bytes[3] << 8),
innerSplitAlpha: Math.min((bits >> 7) & 0x7f, SPLIT_STEPS_MAX) / STEPS_PER_UNIT,
outerSplitAlpha:
(Math.min((bits >> 14) & 0x7f, OUTER_SPLIT_ALPHA_STEPS_MAX) + OUTER_SPLIT_ALPHA_MIN_STEPS) / STEPS_PER_UNIT,
splitSizeRatio: Math.min((bits >> 21) & 0x7f, SPLIT_STEPS_MAX) / STEPS_PER_UNIT,
screenHeight: Math.max(screenHeight, SCREEN_HEIGHT_MIN),
scopeDotScale: Math.min((bytes[22] + SCOPE_DOT_SCALE_MIN_STEPS) / STEPS_PER_UNIT, SCOPE_DOT_SCALE_MAX),
scopeDotUseCrosshairColor: ((bits >>> 28) & 1) === 1,
};
Expand Down Expand Up @@ -165,7 +180,7 @@ function crosshairV1ToBytes(crosshair: CrosshairV1): number[] {
bytes[3] = screenHeight >> 8;
bytes[4] =
clamp(crosshair.style, 0, 9) |
(Number(crosshair.followRecoil) << 4) |
(Number(crosshair.followRecoil) << 5) |
(Number(crosshair.centerDotEnabled) << 6) |
(Number(crosshair.tStyleEnabled) << 7);
bytes[5] = clamp(crosshair.red, 0, 255);
Expand All @@ -176,9 +191,10 @@ function crosshairV1ToBytes(crosshair: CrosshairV1): number[] {
bytes[10] = clamp(crosshair.outlineGreen, 0, 255);
bytes[11] = clamp(crosshair.outlineBlue, 0, 255);
bytes[12] = clamp(crosshair.outlineAlpha, 0, 255);
bytes[13] = clamp(crosshair.thickness, 0, 255);
bytes[14] = clamp(crosshair.outlineMode, 0, 2);
bytes[15] = clamp(crosshair.gap, -128, 127) & 0xff;
bytes[13] = clamp(crosshair.thickness, 0, 32) | (clamp(crosshair.outlineMode, 0, 2) << 6);
const gap = clamp(crosshair.gap, -3840, 3840);
bytes[14] = gap & 0xff;
bytes[15] = (gap >> 8) & 0xff;
bytes[16] = clamp(crosshair.length, 0, 255);
bytes[17] = clamp(crosshair.dynamicSpreadLimit, 0, 255);
bytes[18] = bits & 0xff;
Expand Down
42 changes: 41 additions & 1 deletion src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ describe('Crosshair share code', () => {
},
// Not a code generated by CS2, it exercises the fields left untouched by the samples above.
{
shareCode: 'CS3LZnPmFiVjknQhyb79bfOK9OuhnUqKHtdA6i4WqyKJ8G',
shareCode: 'CSAB6Oby2JUDOrBRy4jRcsR6JOQdhbszKF9GuKQGhKVwAc',
crosshair: {
format: 'cs2-v1',
style: 9,
Expand Down Expand Up @@ -630,6 +630,8 @@ describe('Crosshair share code', () => {
'CSuZhVyU8icUwjTjopBSVi6ctEbq5GiOXJBRdC6LNLKaFL',
// cs2 container code with an invalid checksum
'CSkSCHQKunGr2yPsJr2U3RTd8ycMA2pEs9jWnxK5KjMmWL',
// cs2 container code with a screen height of 0, CS2 rejects it
'CSoQhciXBHZUKAbfexZBeMWFKxkrXL5MLumDLj3JYW4snP',
];

invalidCrosshairCodes.forEach((shareCode) => {
Expand All @@ -638,4 +640,42 @@ describe('Crosshair share code', () => {
}).toThrow(new InvalidCrosshairShareCode());
});
});

it('should clamp out of range cs2 values like CS2 does', () => {
// Crafted code, every field holds the largest value its bits allow, the screen height is 100 and the gap -32768.
expect(decodeCrosshairShareCode('CS7uw3wMkJBJ4rMDLEaYbyfTJFFPvkLAhOmBFi8zOM6H2d')).toEqual({
format: 'cs2-v1',
style: 9,
followRecoil: false,
centerDotEnabled: false,
tStyleEnabled: false,
outlineMode: 2,
red: 1,
green: 2,
blue: 3,
alpha: 4,
outlineRed: 5,
outlineGreen: 6,
outlineBlue: 7,
outlineAlpha: 8,
gap: -3840,
length: 9,
thickness: 32,
dynamicSpreadLimit: 10,
splitDistance: 5,
innerSplitAlpha: 1,
outerSplitAlpha: 1,
splitSizeRatio: 1,
screenHeight: 240,
scopeDotScale: 2,
scopeDotUseCrosshairColor: false,
} satisfies CrosshairV1);
// Same code with a gap of 32767.
expect(decodeCrosshairShareCode('CSDfPcMpj4e9NRxx6SGiVoFCHzYRYGmb34WHpbYHy6E6id')).toMatchObject({ gap: 3840 });
});

it('should round trip a gap outside of the settings UI range', () => {
const crosshair = { ...crosshairV1Samples[0].crosshair, gap: -3000 };
expect(decodeCrosshairShareCode(encodeCrosshair(crosshair))).toEqual(crosshair);
});
});
Loading