CS2 VibeSignatures

July 20, 2026 · View on GitHub

中文文档

This is a project mainly for generating signatures/offsets for CS2, updating HL2SDK_CS2 cpp headers via Agent SKILLS & MCP Calls.

Our goal is to update signatures/offsets/cppheaders without human involved.

Currently, all signatures/offsets from CounterStrikeSharp and CS2Fixes can be updated automatically with this project.

Requirements

  1. uv

  2. depotdownloader (depotdownloader.exe must be available in PATH)

  3. claude / codex

  4. IDA Pro 9.0+

  5. ida-pro-mcp

  6. idalib (mandatory for ida_analyze_bin.py)

  7. Clang-LLVM (mandatory for run_cpp_tests.py, clang.exe must be available in PATH)

Formatting

This repository formats git-tracked *.py files with ruff format and git-tracked *.yaml files with yamlfix.

Run formatting locally before committing:

uv run python format_repo_files.py

Run the same formatting gate used by GitHub Actions:

uv run python format_repo_files.py --check

The formatter only uses files returned by git ls-files --cached -- '*.py' '*.yaml', so ignored files and untracked scratch files are skipped. YAML files under ida_preprocessor_scripts/references/ and canonical lockfiles under gamesymbols/ are also skipped because their own generators control byte-stable formatting.

Overall workflow

1. Download CS2 depot and copy binaries to workspace

uv run download_depot.py -tag 14156

uv run copy_depot_bin.py -gamever 14156 -platform all-platform
uv run copy_depot_bin.py -gamever 14156 -platform all-platform -checkonly

Use -checkonly in CI or preflight scripts when you only need to know whether all expected target binaries already exist under bin/<gamever>/.... In this mode the script only checks target paths, does not require a populated cs2_depot, returns 0 when all expected binaries are ready, 1 when any target is missing, and 2 for configuration or argument errors.

The scheduled Bump Download GitHub Actions workflow keeps this download list fresh. It runs bump_download.py against the CS2 default branch, appends a new download.yaml entry only when the discovered PatchVersion and depot manifests require it, creates the matching local commit/tag, and pushes them from the workflow. For a local preview without writing git state, run:

uv run bump_download.py -config download.yaml -depotdir cs2_depot -dry-run

If DepotDownloader needs authentication, add the same -username, -password, and -remember-password flags used by the workflow.

2. Find and generate signatures for all symbols declared in configs/<GAMEVER>.yaml

uv run ida_analyze_bin.py -gamever 14156 [-oldgamever=14155] [-configyaml=path/to/custom.yaml] [-modules=server] [-skill=find-CBaseEntity_vtable] [-platform=windows] [-agent=claude/codex/opencode/"claude.cmd"/"codex.cmd"/"opencode.cmd"] [-maxretry=3] [-vcall_finder=g_pNetworkMessages|*] [-llm_model=gpt-4o] [-llm_apikey=your-key] [-llm_baseurl=https://api.example.com/v1] [-llm_temperature=0.2] [-llm_effort=medium] [-llm_fake_as=codex] [-rename] [-debug]
  • Shared LLM CLI parameters:

    • -llm_apikey: required when an LLM-backed workflow is enabled, including vcall_finder aggregation and LLM_DECOMPILE
    • -llm_baseurl: optional custom compatible base URL (required when -llm_fake_as=codex)
    • -llm_model: optional, defaults to gpt-4o
    • -llm_temperature: optional; sent only when explicitly set
    • -llm_effort: optional; defaults to medium; supports none|minimal|low|medium|high|xhigh
    • -llm_fake_as: optional; codex switches to direct /v1/responses SSE transport
    • Env fallbacks: CS2VIBE_LLM_APIKEY, CS2VIBE_LLM_BASEURL, CS2VIBE_LLM_MODEL, CS2VIBE_LLM_TEMPERATURE, CS2VIBE_LLM_EFFORT, CS2VIBE_LLM_FAKE_AS
    • LLM workflows do not read OPENAI_API_KEY, OPENAI_API_BASE, or OPENAI_API_MODEL
  • Old signatures from bin/{previous_gamever}/{module}/{symbol}.{platform}.yaml will be used to find symbols in current version of game binaries directly through mcp call before actually running Agent SKILL(s). No token will be consumed in this case.

  • -agent="claude.cmd" is for claude cli installed from Windows npm

  • -agent="opencode.cmd" uses the OpenCode CLI installed through npm on Windows. OpenCode loads the project Agent from .opencode/agents/sig-finder.md and runs skills in non-interactive mode.

  • We prefer programmatic preprocessor scripts > LLM_DECOMPILE based preprocessor scripts > Agent with SKILL.md

  • -skill=<exact-name> runs only skills whose name exactly matches within the active -modules filter. It does not automatically run prerequisite skills; required expected_input artifacts must already exist.

  • when -rename specified, run rename/comment post-processing over existing expected-output YAML files.

  • Redis process reporting is optional. Set CS2VIBE_PROCESS_REPORTER=redis and CS2VIBE_REDIS_URL=redis://127.0.0.1:6379/0, or pass the equivalent -process_reporter=redis, -redis_url=..., and optional -redis_prefix=... flags. The Reporter publishes the immutable execution graph, current Run/Job/Skill snapshots, an event Stream, atomic summary counters, and a TTL heartbeat. Temporary Redis failures do not change the Analyzer result; the latest local snapshots are replayed after reconnection.

  • Queue Analyzer runs with uv run python process_scheduler_cli.py submit --gamever 14141 --agent codex, then start the single-concurrency worker with uv run python process_scheduler_cli.py run. The Redis Stream consumer group preserves FIFO order, recovers pending entries after Scheduler restarts, and will not relaunch a recovered Run while its Analyzer heartbeat is still alive. Queue payloads are validated fields rather than executable shell commands.

  • Start the read-only progress API with uv run uvicorn process_api:app --host 127.0.0.1 --port 8000. The primary routes are /api/v1/runs, /api/v1/runs/{run_id}/snapshot, /tasks, /events, and /stream. Clients should load a snapshot first, then open SSE with its snapshot_event_id as after; reconnects can resume through Last-Event-ID. The service binds to localhost by default and has no built-in authentication, so external deployments should place it behind an authenticated reverse proxy. Configure browser origins with CS2VIBE_API_CORS_ORIGINS, tune SSE through CS2VIBE_SSE_BLOCK_MS and CS2VIBE_SSE_BATCH_SIZE, and use /healthz and /readyz for liveness and Redis readiness. The React dashboard in pages/ is built with npm ci && npm run build. For a public Pages origin that connects to FastAPI on the same browser machine, set the exact origin in CS2VIBE_API_CORS_ORIGINS and opt into private-network preflight responses with CS2VIBE_API_ALLOW_PRIVATE_NETWORK=true. A Pages CDN cannot reach another machine's localhost, and wildcard origins are rejected when private-network access is enabled.

  • -vcall_finder=g_pNetworkMessages filters by an object declared in the module-level vcall_finder config; -vcall_finder=* processes every object declared in configs/<GAMEVER>.yaml.

  • When -vcall_finder is enabled, the script exports full disassembly and pseudocode for each referencing function into vcall_finder/{gamever}/{object_name}/{module}/{platform}/, then runs LLM aggregation after all module/platform IDA work finishes; if a detail YAML already has a top-level found_vcall, that function skips the LLM call and reuses the cached result directly.

  • After a successful LLM response, the script immediately writes back found_vcall: [...] or found_vcall: [] to the corresponding detail YAML so reruns can skip that function's LLM call.

  • vcall_finder/{gamever}/{object_name}.txt is now an appended YAML document stream; each record directly contains insn_va, insn_disasm, and vfunc_offset without a nested found_vcall: wrapper.

uv run ida_analyze_bin.py -gamever=14141 -modules=networksystem -platform=windows -vcall_finder=g_pNetworkMessages -llm_model=gpt-5.4 -llm_apikey=your-key -llm_effort=high -llm_fake_as=codex -llm_baseurl=http://127.0.0.1:8080/v1

Example outputs:

  • vcall_finder/14141/g_pNetworkMessages/networksystem/windows/sub_140123450.yaml
  • vcall_finder/14141/g_pNetworkMessages.txt
  • IDA preprocessor environment:
    • CS2VIBE_STRING_MIN_LENGTH: controls optional IDA string-list setup for preprocessor string enumeration only
    • Unset or empty: do not call idautils.Strings.setup; use the IDB's current string-list state
    • Integer >=1: call idautils.Strings.setup(strtypes=[ida_nalt.STRTYPE_C], minlen=<value>) when the current IDB has not already been set up with the same parameters
    • Non-integer or values <1: fall back to 4 and use the same IDB-level setup guard
    • Setup state is stored per IDB; changing the effective minlen triggers setup again
    • This is not an LLM parameter

reference YAML for LLM_DECOMPILE

Reference YAML path:

  • ida_preprocessor_scripts/references/<module>/<func_name>.<platform>.yaml

Preparation steps:

  1. Confirm the target function already has a current-version YAML with func_va, or can be resolved in IDA by symbol name/alias from configs/<GAMEVER>.yaml.
  2. Run standalone CLI:
uv run generate_reference_yaml.py -gamever 14141 -module engine -platform windows -func_name CNetworkGameClient_RecordEntityBandwidth -mcp_host 127.0.0.1 -mcp_port 13337

Auto-start idalib-mcp example:

uv run generate_reference_yaml.py -gamever 14141 -module engine -platform windows -func_name CNetworkGameClient_RecordEntityBandwidth -auto_start_mcp -binary bin/14141/engine/engine2.dll
  1. Check generated YAML:
    • func_va is credible
    • disasm_code is non-empty and matches target function semantics
    • procedure matches expected semantics when available (it can be an empty string when Hex-Rays is unavailable)
    • func_name only confirms the output file targets your requested canonical name; it does not prove address resolution correctness
  2. Wire it in target find-*.py LLM_DECOMPILE:
    • Generated file path in repository:

      • ida_preprocessor_scripts/references/<module>/<func_name>.<platform>.yaml
    • If LLM_DECOMPILE uses relative path, write:

      • references/<module>/<func_name>.<platform>.yaml
    • Every entry must explicitly declare its accepted result section(s):

      {
          "symbol_name": "INetworkMessages_FindNetworkGroup",
          "prompt_path": "prompt/call_llm_decompile.md",
          "reference_yaml_paths": [
              "references/engine/CNetworkGameClient_RecordEntityBandwidth.{platform}.yaml",
          ],
          "expected_result_sections": ["found_vcall"],
      }
      
    • Valid result sections are found_call, found_vcall, found_funcptr, found_gv, and found_struct_offset.

    • Use multiple reference_yaml_paths for one symbol instead of repeating the same symbol in multiple specs.

    • LLM_DECOMPILE uses the same shared ida_analyze_bin.py -llm_* flags: -llm_model, -llm_apikey, -llm_baseurl, -llm_temperature, -llm_effort, -llm_fake_as

3. Convert yaml(s) to gamedata json / txt

uv run update_gamedata.py -gamever 14168 -snapshot gamesymbols/14168.yaml -modulesdir gamedata-generators -outputdir gamedata/14168 -download_latest -strict [-debug]

4. Run cpp tests and check if cpp headers mismatch from yaml(s)

uv run run_cpp_tests.py -gamever 14168 -snapshot gamesymbols/14168.yaml [-debug]

Use the project-level fix-cppheaders SKILL to repair reported hl2sdk_cs2 header differences. The SKILL runs run_cpp_tests.py itself to collect fresh layout diffs and verify the edits.

5. Publish, restore, and verify the game-symbol snapshot

Per-symbol YAML remains ignored under bin/<GAMEVER>/<module>/. The Git-tracked canonical analysis lockfile is gamesymbols/<GAMEVER>.yaml, whose file set is derived from the required and optional YAML outputs declared by configs/<GAMEVER>.yaml.

After a successful top-level analysis transaction, build one candidate immediately. Both downstream consumers read that same immutable candidate; publication copies its original bytes only after both validations succeed:

CANDIDATE_DIR="$(mktemp -d)"
CANDIDATE_SNAPSHOT="$CANDIDATE_DIR/14168.yaml"
CANDIDATE_SESSION="$CANDIDATE_DIR/14168.session.json"
GAMEDATA_ROOT="$CANDIDATE_DIR/gamedata-candidate"
GAMEDATA_SESSION="$CANDIDATE_DIR/14168.gamedata.session.json"
uv run gamesymbol_candidate.py build -gamever 14168 -bindir bin -configyaml configs/14168.yaml -output "$CANDIDATE_SNAPSHOT" -session "$CANDIDATE_SESSION"
uv run gamedata_candidate.py build -gamever 14168 -build-id local-1 -snapshot "$CANDIDATE_SNAPSHOT" -configyaml configs/14168.yaml -candidate-root "$GAMEDATA_ROOT" -session "$GAMEDATA_SESSION"
uv run gamedata_candidate.py guard -session "$GAMEDATA_SESSION"
uv run gamesymbol_candidate.py mark -candidate "$CANDIDATE_SNAPSHOT" -session "$CANDIDATE_SESSION" -step gamedata
uv run run_cpp_tests.py -gamever 14168 -configyaml configs/14168.yaml -snapshot "$CANDIDATE_SNAPSHOT"
uv run gamesymbol_candidate.py mark -candidate "$CANDIDATE_SNAPSHOT" -session "$CANDIDATE_SESSION" -step cpp_tests
uv run gamesymbol_candidate.py publish -candidate "$CANDIDATE_SNAPSHOT" -session "$CANDIDATE_SESSION" -snapshot gamesymbols/14168.yaml
uv run gamedata_candidate.py publish -session "$GAMEDATA_SESSION" -outputdir gamedata/14168

Restore a clean analysis baseline or verify the current workspace without modifying the tracked snapshot:

uv run gamesymbol_snapshot.py restore -gamever 14168
uv run gamesymbol_snapshot.py restore -gamever 14168 -replace
uv run gamesymbol_snapshot.py verify -gamever 14168
uv run gamesymbol_snapshot.py check-contract -gamever 14168 -json
uv run gamesymbol_snapshot.py migrate -gamever 14168

Default restore creates missing YAML and refuses to overwrite semantically different files. -replace removes only YAML under bin/<GAMEVER>/, preserves binaries and IDA databases, then rebuilds the snapshot contents. Candidate build and low-level/bootstrap pack reject missing required outputs and undeclared YAML. verify enforces canonical bytes and both required round trips. Schema 1 snapshots imply frozen config digest v1 and remain byte-stable; new writers emit schema 2 with explicit, domain-separated config digest v2. check-contract is a read-only trust probe: exit 0 means trusted, exit 3 reports a machine-readable untrusted reason, and invocation/config or operational errors remain hard failures. migrate explicitly upgrades a validated schema-1 snapshot without changing its files payload; it never runs implicitly during restore or verify.

Pull requests that can affect analysis output must commit the matching gamesymbols/<GAMEVER>.yaml update. PR CI uses a trusted base snapshot for restore and targeted invalidation. A missing base snapshot bootstraps from clean YAML; an untrusted base snapshot emits a warning and takes the same clean full-rebuild path without restoring any baseline payload. The workflow then strict-packs an actual candidate and compares it with the PR head snapshot. Head snapshots, actual candidates, release promotion, and republish remain strict: none use the baseline warning fallback. The head snapshot is expected-only; both downstream consumers use the actual candidate, and the ordinary PR workflow never publishes or rewrites tracked bytes.

Currently supported gamedata

CounterStrikeSharp

gamedata/<GAMEVER>/CounterStrikeSharp/config/addons/counterstrikesharp/gamedata/gamedata.json

  • 2 skipped symbols.

  • GameEventManager: not used anymore by CSS.

  • CEntityResourceManifest_AddResource: barely changes on game update.

CS2Fixes

gamedata/<GAMEVER>/CS2Fixes/gamedata/cs2fixes.jsonc

  • CCSPlayerPawn_GetMaxSpeed skipped because it is not a thing in server.dll

swiftlys2

gamedata/<GAMEVER>/swiftlys2/plugin_files/gamedata/cs2/core/offsets.jsonc

gamedata/<GAMEVER>/swiftlys2/plugin_files/gamedata/cs2/core/signatures.jsonc

plugify

gamedata/<GAMEVER>/plugify-plugin-s2sdk/assets/gamedata.jsonc

cs2kz-metamod

gamedata/<GAMEVER>/cs2kz-metamod/gamedata/cs2kz-core.games.txt

modsharp

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/core.games.jsonc

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/engine.games.jsonc

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/EntityEnhancement.games.jsonc

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/log.games.jsonc

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/server.games.jsonc

gamedata/<GAMEVER>/modsharp-public/.asset/gamedata/tier0.games.jsonc

CS2Surf/Timer

gamedata/<GAMEVER>/cs2surf/gamedata/cs2surf-core.games.jsonc

How to create SKILL for vtable

CCSPlayerPawn for example.

Claude Code:

/create-preprocessor-scripts Create "find-CCSPlayerPawn_vtable" in server.

How to create SKILL for regular function

CItemDefuser_Spawn and CBaseModelEntity_SetModel for example

1.Look for desired symbols in IDA

  • Search string "weapons/models/defuser/defuser.vmdl" in IDA, look for code snippet with following pattern in xrefs to the string:
    v2 = a2;
    v3 = (__int64)a1;
    sub_180XXXXXX(a1, (__int64)"weapons/models/defuser/defuser.vmdl"); //This is CBaseModelEntity_SetModel, rename it to CBaseModelEntity_SetModel
    sub_180YYYYYY(v3, v2);
    v4 = (_DWORD *)sub_180ZZZZZZ(&unk_181AAAAAA, 0xFFFFFFFFi64);
    if ( !v4 )
      v4 = *(_DWORD **)(qword_181BBBBBB + 8);
    if ( *v4 == 1 )
    {
      v5 = (__int64 *)(*(__int64 (__fastcall **)(__int64, const char *, _QWORD, _QWORD))(*(_QWORD *)qword_181CCCCCC + 48i64))(
                        qword_181CCCCCC,
                        "defuser_dropped",
                        0i64,
                        0i64);

The function with this code snippet is CItemDefuser_Spawn

2. Create preprocessor script and update configs/<GAMEVER>.yaml

Claude Code:

/create-preprocessor-scripts Create "find-CItemDefuser_Spawn" in server by xref_strings "weapons/models/defuser/defuser.vmdl" "defuser_dropped", where CItemDefuser_Spawn is a vfunc of CItemDefuser_vtable.

Claude Code:

/create-preprocessor-scripts Create "find-CBaseModelEntity_SetModel" in server by LLM_DECOMPILE with "CItemDefuser_Spawn", where CBaseModelEntity_SetModel is a regular function being called in "CItemDefuser_Spawn".

How to create SKILL for global variable

IGameSystem_InitAllSystems_pFirst for example

1. Look for desired symbols in IDA

  • Search string "IGameSystem::InitAllSystems" in IDA, search xrefs for the string. the function with xref to the string is IGameSystem_InitAllSystems.

  • Rename it to IGameSystem_InitAllSystems if not renamed yet.

  • Look for code pattern at very beginning of IGameSystem_InitAllSystems: "( i = qword_XXXXXX; i; i = *(_QWORD *)(i + 8) )"

  • Rename qword_XXXXXX previously found to IGameSystem_InitAllSystems_pFirst if it was not renamed yet.

2. Create preprocessor script and update configs/<GAMEVER>.yaml

Claude Code:

/create-preprocessor-scripts Create "find-IGameSystem_InitAllSystems" in server by xref_strings "IGameSystem::InitAllSystems", where IGameSystem_InitAllSystems is a regular func.

Claude Code:

/create-preprocessor-scripts Create "find-IGameSystem_InitAllSystems_pFirst" in server by LLM_DECOMPILE with "IGameSystem_InitAllSystems", where IGameSystem_InitAllSystems_pFirst is a global variable being used in "IGameSystem_InitAllSystems".

How to create SKILL for struct offset

CGameResourceService_m_pEntitySystem for example.

1. Look for desired symbols in IDA

  • Search string "CGameResourceService::BuildResourceManifest(start)" in IDA, search xrefs for the string.

  • The xref should point to a function - this is CGameResourceService_BuildResourceManifest. rename it to CGameResourceService_BuildResourceManifest if not renamed yet.

2. Create preprocessor script and update configs/<GAMEVER>.yaml

Claude Code:

/create-preprocessor-scripts Create "find-CGameResourceService_BuildResourceManifest" in engine by xref_strings "CGameResourceService::BuildResourceManifest(start)" , where CGameResourceService_BuildResourceManifest is a vfunc of CGameResourceService_vtable.
/create-preprocessor-scripts Create "find-CGameResourceService_m_pEntitySystem" in engine by LLM_DECOMPILE with "CGameResourceService_BuildResourceManifest", where CGameResourceService_m_pEntitySystem is a struct offset.

How to create SKILL for patch

  • A patch SKILL locates a specific instruction inside a known function and generates replacement bytes to change its behavior at runtime (e.g., force/skip a branch, NOP a call). The target function should already have a corresponding find-SKILL output available (typically via expected_input).

  • Always make sure you have ida-pro-mcp server running.

  • For human contributor: You should write new initial prompts when looking for new symbols, DO NOT COPY-PASTE the initial prompts from README!!!

CCSPlayer_MovementServices_FullWalkMove_SpeedClamp for example — patching the velocity clamping jbe to an unconditional jmp inside CCSPlayer_MovementServices_FullWalkMove.

1. Look for desired symbols in IDA

  • Decompile CCSPlayer_MovementServices_FullWalkMove and look for code pattern - whatever a float > A square of whatever a float:
  v20 = (float)((float)(v16 * v16) + (float)(v19 * v19)) + (float)(v17 * v17);
  if ( v20 > (float)(v18 * v18) )
  {
    ...velocity clamping logic...
  }
  • Disassemble around the comparison to find the exact conditional jump instruction.

  • Disassemble around the comparison address to find the comiss + jbe instruction pair.

  Expected assembly pattern:
    addss   xmm2, xmm1          ; v20 = sum of squares
    comiss  xmm2, xmm0          ; compare v20 vs v18*v18
    jbe     loc_XXXXXXXX         ; skip clamp block if v20 <= v18*v18
  • Determine the patch bytes based on the instruction encoding.
  * Near `jbe` (`0F 86 rel32` — 6 bytes) → `E9 <new_rel32> 90` (unconditional `jmp` + `nop`)
  * Short `jbe` (`76 rel8` — 2 bytes) → `EB rel8` (unconditional `jmp short`)

2. Create preprocessor script and update configs/<GAMEVER>.yaml

Follow .claude/skills/create-preprocessor-scripts/SKILL.md to create the preprocessor script and update the requested configs/<GAMEVER>.yaml.

Troubleshooting

error: could not create 'ida.egg-info': access denied

Mitigation: You should run python py-activate-idalib.py under C:\Program Files\IDA Professional 9.0\idalib\python with administrator privilege.

Could not find idalib64.dll in .........

Mitigation: Try set IDADIR=C:\Program Files\IDA Professional 9.0 or add IDADIR=C:\Program Files\IDA Professional 9.0 to your system environment.

CI/CD workflow references

@echo Download latest game binaries

uv run download_bin.py -gamever %CS2_GAMEVER%
@echo Analyze game binaries

uv run ida_analyze_bin.py -gamever %CS2_GAMEVER% -agent="claude.cmd" -platform %CS2_PLATFORM% -debug
@echo Build the immutable candidate immediately after analysis

set CANDIDATE_ID=%RANDOM%
set CANDIDATE_SNAPSHOT=%TEMP%\gamesymbol-%CS2_GAMEVER%-%CANDIDATE_ID%.yaml
set CANDIDATE_SESSION=%TEMP%\gamesymbol-%CS2_GAMEVER%-%CANDIDATE_ID%.session.json
uv run gamesymbol_candidate.py build -gamever %CS2_GAMEVER% -bindir bin -configyaml configs/%CS2_GAMEVER%.yaml -output %CANDIDATE_SNAPSHOT% -session %CANDIDATE_SESSION%
@echo Update gamedata from the candidate snapshot

uv run update_gamedata.py -gamever %CS2_GAMEVER% -snapshot %CANDIDATE_SNAPSHOT% -modulesdir gamedata-generators -outputdir gamedata/%CS2_GAMEVER% -download_latest -strict -debug
uv run gamesymbol_candidate.py mark -candidate %CANDIDATE_SNAPSHOT% -session %CANDIDATE_SESSION% -step gamedata
@echo Find mismatches in CS2SDK headers

uv run run_cpp_tests.py -gamever %CS2_GAMEVER% -snapshot %CANDIDATE_SNAPSHOT% -debug
uv run gamesymbol_candidate.py mark -candidate %CANDIDATE_SNAPSHOT% -session %CANDIDATE_SESSION% -step cpp_tests
uv run gamesymbol_candidate.py publish -candidate %CANDIDATE_SNAPSHOT% -session %CANDIDATE_SESSION% -snapshot gamesymbols/%CS2_GAMEVER%.yaml