Skip to content

Latest commit

 

History

History
309 lines (216 loc) · 15.5 KB

File metadata and controls

309 lines (216 loc) · 15.5 KB

CS2//CPU

A RISC-V processor implemented in the console of Counter-Strike 2 using a pile of .cfg configuration files.

It relies on no mods, plugins, or external programs — it is assembled entirely from the Source 2 engine's built-in console commands such as alias, exec, and echo. It supports the RV32I base instruction set + Zmmul multiplication extension, and can genuinely fetch, decode, and execute instructions, running programs like helloworld and fibonacci. In theory it can be used on VAC-protected servers such as competitive mode, requiring neither sv_cheats 1 nor any external assistance, with output via say_team.

Strictly speaking it is not a "chip" but a CPU emulator written on top of the console alias system and computing at hexadecimal bit-width. The fact that it runs at all — as the author puts it — is already a miracle.


Table of Contents


Features

  • Pure .cfg implementation: no C++, no plugins — all logic is Source 2 console commands.
  • Complete RV32I base instruction set: all six instruction formats — R / I / S / B / U / J — are covered.
  • Zmmul multiplication extension: supports mul, mulh, mulhsu, mulhu (no division).
  • 32 general-purpose registers x0–x31: x0 is hardwired to 0, per the RISC-V spec.
  • 64 KB of addressable memory: byte-granularity read/write, with sign-extending lb/lh/lbu/lhu/lw and sb/sh/sw.
  • System calls: character output, character input, and halt via ecall.
  • Built-in ELF→storage conversion toolchain: cross-compiled RISC-V ELF files can be loaded directly into cfg memory.
  • Three run modes: manual single-step, continuous, and asynchronous background execution.

Quick Start

1. Install the CPU core

Copy all files inside ./Main (note: not the Main folder itself) into CS2's cfg folder, for example:

D:\SteamLibrary\steamapps\common\Counter-Strike Global Offensive\game\csgo\cfg

2. Load a program (memory image)

Pick a preset storage from ./Storages (e.g. helloworld) and place the entire folder (this time including the folder) into the same CS2 cfg folder above.

Preset images:

Folder Contents
Storages/helloworld A C program that prints Hello world!
Storages/fibonacci The Fibonacci sequence
Storages/speedtest An image used for performance testing

3. Run

Since output uses say_team, first enter a practice-mode map, then open the console and run:

exec cpu_run_manual
loop

Afterwards, every time a character is output, you must manually type loop again to continue (this prevents the console from flooding and freezing).


Run Modes

Main provides three entry points:

Entry Command Description
Manual exec cpu_run_manual Type loop again after each output character; most stable
Continuous exec cpu_run Just loop straight to the end
Async exec cpu_run_async Loops in the background via exec_async, yielding frames with break

Related control commands (see Main/cpu_predefine.cfg):

  • cpu_pause: pause execution; afterwards loop no longer continues.
  • cpu_resume: resume execution.
  • exit: halt the program, printing !!!程序运行完毕!!!.

Output is uniformly prefixed with [[CPU]] and shown in team chat via say_team. Control characters (newline, carriage return, etc.) are displayed in a readable form such as [LF], [CR].


Compiling Your Own Programs

The scripts that load ELF / binary into memory are all under ./Compiling, ultimately producing a storage folder (i.e. the RAM image).

Method 1: C

  1. Write your C code in ./Compiling/C (see helloworld.c and syscall.h for reference).
  2. Run Compile.py; it invokes make to cross-compile and outputs a .elf into ./Compiling/Mem.
  3. Run Store.py, select the ELF file, enter the chunk count and stack chunk count, and the generated storage is placed into ./Compiling/storage.

You can also directly run 编译+储存.py: this is a rough compilation script that automatically compiles the code in ./Compiling/C, generates the storage, and copies it to ./Main/storage, overwriting the current memory image.

Method 2: Assembly

  • Assembly with riscv-none-elf-as.py: uses the GNU toolchain (riscv-none-elf-as / ld) to assemble .asm files in ./Compiling/Assembly into .elf.
  • Assembly with rvasm.py: uses the lightweight assembler from pip install rvasm, producing a .mem (hex text).

After compiling, run Store.py as well to write it into cfg storage.


Supported Instruction Set

Instruction decoding is done in Main/inst/cpu_inst_predefine.cfg, dispatching by opcode prefix to each instruction type:

Type Instructions
R add sub and or xor sll srl sra slt sltu
I addi andi ori xori slti sltiu slli srli srai
L lb lh lw lbu lhu
S sb sh sw
B beq bne blt bge bltu bgeu
J jal jalr
U lui auipc
SYSTEM ecall ebreak
Zmmul mul mulh mulhsu mulhu

fence / fence.i (the FENCE class) is recognized but ignored — a single-threaded CPU has no use for memory barriers.


System Calls

ecall dispatches based on the value of a7 (x17) (see Main/inst/cpu_inst_ecall.cfg):

Number Name Behavior
10 exit Halt
11 print_char Output the low 8 bits of a0 (x10) as a character
12 read_char Pause and read a character from the console into a0

The corresponding C inline wrappers are in Compiling/C/syscall.h: sys_exit(), sys_print_char(), sys_read_char(), sys_print().


How It Works

The Source engine's alias is both "variable" and "function"

The Source engine's alias is essentially a text-substitution macro: when executing a line, the console expands an alias into the string it stores and then executes it piece by piece. Therefore:

  • Variable: alias rX0 0 — defines rX0 as a "value"; reading it performs the substitution.
  • Assignment: alias rX0 5 — redefines rX0.
  • Function call: alias foo "echo hi", then executing foo invokes it.
  • Branching / conditionals: using alias 0 ... / alias 1 ... maps a single hexadecimal or binary digit to different follow-up actions.

The whole CPU is woven from thousands of lines of such aliases: fetch → decode → read registers → compute → write back → increment PC, expanded and executed step by step in the console.

Why hexadecimal instead of binary

This project uses hexadecimal as its fundamental bit-width. One hexadecimal digit (0–f) represents 4 bits, and a lookup table from alias 0 ... to alias f ... can process a whole nibble in one step; if pure binary were used instead, the number of expansion layers for instructions and data would multiply several times over, making the already-glacial speed completely unwatchable.

Internal registers

The CPU uses several "internal registers" for data movement (defined in Main/cpu_inner_registers.cfg):

  • rL: 8 bits (2 hex digits), initial memory read/write.
  • rM: 32 bits (8 hex digits), staging for general-purpose register read/write.
  • rX / rY: 32 bits, operation operands.
  • rT: 32 bits, temporary buffer.
  • rI: the current instruction; bI: the instruction's binary representation.
  • rP: the program counter (PC, 4 hex digits, covering the 64 KB address space).
  • rR: the memory read pointer (a memory read pointer independent of rP, 4 hex digits, covering the 64 KB address space).

Each "register" is really a chain of alias digits rXn (n=0–7), paired with "loaders" like rXncl to implement per-digit read/write via lookup tables.

Instruction lifecycle

Taking a single addi as an example, the rough flow is:

  1. rP points at the current instruction address; through memory addressing, the 32-bit instruction is read into rM / rI / bI.
  2. cpu_inst_step reads the low 7 binary bits of the opcode one by one, hits opcode_I via the op_load lookup table.
  3. cpu_inst_type_i extracts the immediate, rs1, funct3, and rd; loads rs1 into rX and the immediate into rY.
  4. Executes cpu_logic_add_y_to_x (addition is done by the big lookup table generated by Scripts/generators/AddGenerate.py).
  5. Writes the result back to rd, increments rP by 4, and moves to the next instruction.

Logic operations (add/subtract, and/or/xor, shifts, comparisons, multiplication) all live in Main/logic/, and most are pre-generated large lookup tables — the 16×16 results are expanded directly into aliases in exchange for "one-step" execution.


Memory Model

  • The theoretical total space is 64 KB, addressed from 0x0000 using 4 hex digits (16 bits).
  • Memory is divided into 256-byte "chunks", each corresponding to a cpu_storage_chunkXX.cfg. Because the complete 64 KB would require far too many files and aliases, this project only generates the chunks containing the memory used by the program and the stack memory.
  • Each byte is stored as two hex characters; the addresser cpu_adr_chunkXX.cfg routes a 4-digit address to the specific chunk and byte.
  • Several chunks at the high addresses are reserved for use as the stack (the stack chunk count is specified when Store.py generates it).
  • Out-of-bounds access hits out_load, which replaces the read/write operation with a no-op, so the console never crashes.

Each generated chunk is further split into several sub-files (__c_0, __c1_0, __lp, etc.), because the alias expansion volume in a single cfg is too large and must be loaded in shards via exec.


Project Structure

RiscV/
├── Main/                  # CPU core (all the cfg logic)
│   ├── cpu_main.cfg       # Entry point, loads each part in order
│   ├── cpu_predefine*.cfg # Predefined "constants/tools/aliases"
│   ├── cpu_registers*.cfg # x0–x31 general-purpose registers
│   ├── cpu_inner_registers*.cfg # Internal registers rL/rM/rX/rY/rT/rI/rP
│   ├── cpu_run*.cfg       # The three run-mode entry points
│   ├── cpu_system_*.cfg   # System calls (putchar / readchar)
│   ├── inst/              # Instruction decoding and per-type implementations
│   ├── logic/             # Arithmetic/logic/shift/compare/multiply units
│   ├── storage/           # Currently loaded memory image (overwritten by compile scripts)
│   ├── _cpu_testcases.cfg # Test cases
│   └── _debug/            # Debug memory image
├── Compiling/             # Program → storage compile/load tooling
│   ├── Compile.py         # Cross-compiles C code
│   ├── Store.py           # Parses ELF/.mem and writes into cfg storage
│   ├── 编译+储存.py        # One-click compile + load + copy into Main
│   ├── makefile           # Cross-compilation rules
│   ├── compact.ld         # Linker script (64KB, code starts at 0)
│   ├── C/                 # C sources (helloworld.c, syscall.h)
│   ├── Assembly/          # Assembly tests (speedtest.asm, bugtest.asm)
│   ├── CLI/               # cli_beautify terminal beautification utility
│   └── Mem/               # Build artifacts (.elf/.o/.mem)
├── Scripts/               # Development helper scripts
│   ├── 0ExpansionToolkits.py # Interactive alias loop-expansion tool
│   └── generators/        # Generators for lookup logic (add, shift, and/or, etc.)
├── Storages/              # Preset memory images (helloworld / fibonacci / speedtest)
├── testcase制造方法.md     # How to create test cases
└── README.md

Dependencies & Environment

To run the CPU you only need CS2 itself — no additional software. The items below are only needed when compiling your own programs or writing cfg code:

Dependency Purpose Install
riscv-none-elf- GNU toolchain Cross-compile C / assembly Install manually (the prefix can be changed at the top of the scripts)
mingw32-make Run the makefile on Windows Ships with MinGW
Python 3 Run the compile/load scripts python.org
pyperclip Clipboard read/write in 0ExpansionToolkits.py pip install pyperclip
rvasm Lightweight assembly (optional path) pip install rvasm

Compiling/CLI/cli_beautify.py depends on ctypes / msvcrt on Windows and on termios on Linux/macOS, so it is cross-platform.


Performance

Measured via Compiling/Assembly/speedtest.asm (300 addi + halt), the execution rate reaches an astonishing:

9.1 Hz

Yes — not MHz, not KHz, but Hz.

Due to the limitations of the language itself (alias is text substitution, not native instructions), the whole CPU has no "parallel execution" whatsoever, and every instruction goes through countless text expansions and lookups. Honestly, the fact that this thing runs at all is itself a miracle.


AI Usage

The main CPU emulator code was written entirely by a human, "handcrafted the old-fashioned way." On the AI side, Deepseek-V4-flash was primarily used. The following are the parts generated or assisted by AI:

  • The .elf reading in Compiling/Store.py (parse_elf)
  • The assembly tests under Compiling/Assembly/ (for testing speed and bugs; entirely AI-generated)
  • C-related code (used to generate RAM, largely unrelated to the core CPU code)
    • Compiling/C/syscall.h
    • Compiling/C/helloworld.c
    • Compiling/CLI/* terminal beautification utility
    • Compiling/compact.ld linker script
    • Compiling/makefile
  • Scripts/0ExpansionToolkits.py expansion tool — the base framework and hexadecimal expansion were done by AI
  • During development, the AI was consulted to learn the rv32i_zmmul instruction set
  • Some test cases in Main/_cpu_testcases.cfg were generated by AI
  • This README.md was polished and expanded by AI based on readme_待润色.md

FAQ

Q: Why do I have to enter a practice-mode map? A: Output uses say_team, and team chat is only displayed properly inside a server/map (practice mode is enough).

Q: Why do I have to type loop again after each output character? A: The CS2 engine does not allow say_team to be executed too rapidly. Using echo instead would drown the output in the large amount of information produced by exec. Switching to cpu_run (continuous) or cpu_run_async (async, requires sv_cheats 1) avoids the manual loop, but you first need to replace say_team with echo in the output cfg code.

Q: Why doesn't pause work? A: pause is a reserved CS2 command; the project uses cpu_pause / cpu_resume instead.

Q: How complex a program can it run? A: Memory is only 64 KB and it is extremely slow, so it suits small demo programs (helloworld, fibonacci, etc.) and educational / meme purposes.

Q: Can I modify the instruction set myself? A: Yes. Decoding is in Main/inst/cpu_inst_predefine.cfg, each instruction in Main/inst/, the compute units in Main/logic/, and the generators in Scripts/generators/.