diff --git a/doc/code_layout.md b/doc/code_layout.md index fb1f294..0f882d1 100644 --- a/doc/code_layout.md +++ b/doc/code_layout.md @@ -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. @@ -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. diff --git a/src/crosshair.ts b/src/crosshair.ts index 86cd718..ead9388 100644 --- a/src/crosshair.ts +++ b/src/crosshair.ts @@ -7,7 +7,6 @@ import { DICTIONARY, InvalidCrosshairShareCode, sumArray, - uint8ToInt8, } from './share-code.js'; /** @@ -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 @@ -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], @@ -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, }; @@ -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); @@ -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; diff --git a/src/index.test.ts b/src/index.test.ts index facf870..3f9c81b 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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, @@ -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) => { @@ -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); + }); });