20 KiB
Static CmdId Dumper (StaticCmdIdDumper) Architectural Specification & Machine-Code Deep Dive
This technical specification provides an exhaustive, code-level analysis of StaticCmdIdDumper (by Hiro420), an ultra-minimalist, zero-dependency C# (.NET 8.0) static binary analysis utility. The tool is engineered to extract network packet Command IDs (CmdId / Opcode) directly from compiled IL2CPP game executables (such as YuanShen.exe, StarRail.exe, or GameAssembly.dll) on disk without launching the game process or relying on third-party disassemblers (such as Capstone or Iced).
1. Executive Summary & Architectural Purpose
1.1 The Protocol Command ID Problem
In modern anime titles developed on Unity IL2CPP (Genshin Impact, Honkai: Star Rail, Zenless Zone Zero), client-server communication relies on an enumerated command opcode (CmdId) identifying each Protobuf packet.
Inside the game client assemblies, each packet class contains a getter method returning its unique opcode:
public class GetAvatarDataCsReq : IMessage {
public override ushort GetCmdId() {
return 384; // 0x0180
}
}
In compiled releases:
- Method names are stripped or obfuscated (e.g.,
GetCmdId()\rightarrowKOOKJNLBAMH()). - Tools like
Firefly-Static-Parserorhonkai-dumpercan identify the Relative Virtual Address (RVA) of these getter methods. - However, obtaining the actual integer value of the opcode traditionally required:
- Attaching a live debugger / Frida script to invoke the method at runtime, or
- Opening a 100MB+ binary in IDA Pro / Ghidra and manually decompiling thousands of tiny stub functions.
1.2 The StaticCmdIdDumper Solution
StaticCmdIdDumper demonstrates an elegant, lightweight alternative:
- It exploits the deterministic machine-code pattern emitted by C++ compilers when compiling a constant 16-bit integer getter.
- It parses PE (Portable Executable) headers using pure, hand-written C# structs.
- It maps the getter method's RVA directly to disk file offset, reads exactly 4 bytes, and extracts the embedded immediate integer via a micro-disassembler.
+----------------------------------------------------------------------------------------------------+
| StaticCmdIdDumper Architecture |
+----------------------------------------------------------------------------------------------------+
| |
| [ Inputs ] |
| 1. Binary File (e.g. YuanShen.exe / GameAssembly.dll) |
| 2. list.txt: Mapping of <ObfuscatedName> <RVA> |
| Example: "KOOKJNLBAMH 0x0CCA3780" |
| |
| [ Pipeline: Zero External Dependencies ] |
| |
| +----------------------------------------------------------------------------------------------+ |
| | 1. PEHeader.cs & Misc.cs | |
| | - Parse DOS Header ('MZ') -> e_lfanew -> PE Signature ('PE\0\0') | |
| | - Parse OptionalHeader64 -> Extract Section Table | |
| | - Locate Target Section ('il2cpp' or '.text') | |
| +----------------------------------------------+-----------------------------------------------+ |
| | |
| v |
| +----------------------------------------------------------------------------------------------+ |
| | 2. File Offset Translation | |
| | FileOffset = Section.PointerToRawData + (MethodRVA - Section.VirtualAddress) | |
| +----------------------------------------------+-----------------------------------------------+ |
| | |
| v |
| +----------------------------------------------------------------------------------------------+ |
| | 3. Targeted 4-Byte Seek & Read | |
| | fs.Seek(FileOffset) -> fs.Read(buffer, 0, 4) | |
| +----------------------------------------------+-----------------------------------------------+ |
| | |
| v |
| +----------------------------------------------------------------------------------------------+ |
| | 4. Micro-Disassembler (Disassembler.cs) | |
| | Matches x86-64 pattern: 66 B8 [imm16] --> "mov ax, imm16" | |
| | Extracts Little-Endian uint16 CmdId | |
| +----------------------------------------------+-----------------------------------------------+ |
| | |
| v |
| [ Output ] |
| PacketIds.json: |
| { |
| "KOOKJNLBAMH": 1234, |
| "MGDFLHGODLH": 4567 |
| } |
| |
+----------------------------------------------------------------------------------------------------+
2. Codebase Organization & Engineering Philosophy
The repository consists of a single C# project targeting .NET 8.0:
StaticCmdIdDumper/
├── StaticCmdIdDumper.sln
└── StaticCmdIdDumper/
├── StaticCmdIdDumper.csproj # Zero external package references (<PackageReference>)
├── PEHeader.cs # Complete Win32/PE64 data structure definitions & binary readers
├── Misc.cs # Section search, header dumpers, and file parsing utilities
├── Disassembler.cs # 50-line custom x86-64 machine-code instruction decoder
└── Program.cs # CLI coordinator & JSON serializer
2.1 The Zero-Dependency Design
Modern .NET reverse-engineering tools typically pull in large third-party dependencies:
AsmResolver.PEorPeNetfor PE parsing.IcedorGee.External.Capstonefor disassembly.
StaticCmdIdDumper explicitly rejects external packages, implementing:
- Hand-coded binary reader routines (
Read16LE,Read32LE,Read64LE). - Hand-crafted PE structures mirroring Windows
winnt.h(IMAGE_DOS_HEADER,IMAGE_NT_HEADERS64,IMAGE_SECTION_HEADER). - Direct byte pattern recognition for the specific instruction opcode emitted by MSVC / Clang.
3. Machine Code Mechanics: IL2CPP Opcode Getters
3.1 C++ to Native x86-64 Compilation
In Unity's IL2CPP output, a constant getter function in C++ is written as:
uint16_t GetCmdId() {
return 1234; // 0x04D2
}
When compiled by MSVC or Clang for x86-64 with standard optimizations (/O2 or -O3), the compiler emits:
mov ax, 0x04D2
ret
3.2 Binary Instruction Encoding
The x86-64 instruction encoding for mov ax, imm16 is:
$$\begin{array}{|c|c|c|c|} \hline \mathbf{0x66} & \mathbf{0xB8} & \mathbf{\text{Byte}_0} & \mathbf{\text{Byte}_1} \ \hline \text{Prefix (Operand Size)} & \text{Opcode (mov ax)} & \text{Imm16 (Low)} & \text{Imm16 (High)} \ \hline \end{array}$$
0x66(Operand-Size Override Prefix): The default operand size in 64-bit mode is 32-bit. The0x66prefix instructs the processor to decode the instruction for the 16-bit registeraxinstead of 32-biteax.0xB8: Opcode formov reg16/32, imm16/32(specifically targeting accumulator registerax).Byte 0&Byte 1: 16-bit little-endian immediate value representing theCmdId.0xC3: The subsequent byte (at offset +4) is theretopcode.
3.3 The Custom Micro-Disassembler (Disassembler.cs)
Because the target functions follow this invariant structure, Disassembler.cs decodes the opcode in just a few lines:
public static int Disassemble(byte[] code)
{
int ip = 0;
while (ip < code.Length)
{
byte opcode = code[ip];
switch (opcode)
{
case 0x66: // 16-bit operand override
{
if (ip + 1 < code.Length && code[ip + 1] == 0xB8)
{
if (ip + 3 < code.Length)
{
// Read 16-bit unsigned little-endian integer
ushort imm16 = BitConverter.ToUInt16(code, ip + 2);
return imm16;
}
}
ip += 1;
break;
}
default:
ip += 1;
break;
}
}
return 0;
}
4. PE Header Parsing & RVA-to-Offset Translation
4.1 PE64 Structural Traversal
In PEHeader.cs and Misc.cs, the program traverses the binary structure:
- DOS Header:
- Reads
dosHeader.magic(must equal0x5A4D/"MZ"). - Reads
dosHeader.e_lfanewat offset0x3C, which points to the NT PE header.
- Reads
- PE Header & Optional Header:
- Validates
dosHeader.pe.signature == 0x00004550("PE\0\0"). - Determines architecture from
optionalHeader.magic:0x10B: 32-bit PE (x86)0x20B: 64-bit PE (x86_64)
- Reads
numberOfSectionsandoptionalHeaderSize.
- Validates
- Section Table Traversal (
ReadSections):- Iterates through
IMAGE_SECTION_HEADERentries (40 bytes each):dosHeader.section_table[idx].name = ReadStr(reader, 8); dosHeader.section_table[idx].virtualSize = Read32LE(reader); dosHeader.section_table[idx].virtualAddr = Read32LE(reader); dosHeader.section_table[idx].sizeOfRawData = Read32LE(reader); dosHeader.section_table[idx].ptrToRawData = Read32LE(reader);
- Iterates through
4.2 Locating the IL2CPP Section (Misc.GetBaseAdd)
In certain compiled anime games (notably Genshin Impact's YuanShen.exe), the linker places compiled game code into a custom named section:
for (int idx = 0; idx < dosHeader.pe.numberOfSections; idx++)
{
if (sections![idx].name != "il2cpp")
{
continue;
}
return sections[idx];
}
4.3 RVA to Disk File Offset Translation
Memory-mapped virtual addresses (VAs and RVAs) differ from raw disk offsets due to section alignment. The conversion formula is:
\text{FileOffset} = \text{Section.PointerToRawData} + (\text{RVA}_{\text{target}} - \text{Section.VirtualAddress})
In Program.cs:
ulong CmdIDTestAddr = Convert.ToUInt64(pair.Value.Substring(2), 16);
var fileoffset = il2cpp_section.ptrToRawData + (CmdIDTestAddr - il2cpp_section.virtualAddr);
byte[] buffer = new byte[4];
using (FileStream fs = new FileStream(args[0], FileMode.Open, FileAccess.Read))
{
fs.Seek((long)fileoffset, SeekOrigin.Begin);
fs.Read(buffer, 0, 4);
}
int cmdid = Disassembler.Disassemble(buffer);
packetIds[pair.Key] = cmdid;
5. Execution Workflow & Serialization
5.1 Input Format (list.txt)
The tool accepts a plain whitespace-delimited text file containing pairs of function identifiers and hexadecimal RVAs:
KOOKJNLBAMH 0x0CCA3780
MGDFLHGODLH 0x0BE5AA60
CGKBCOJMLPC 0x082AB170
OOMIIJFHPFN 0x0D16A280
[!NOTE] Origin of the Input List This list is directly obtained from the output of
Firefly-Static-Parserorhonkai-dumper. Inmethods.json, getter methods for packet IDs appear as:"KOOKJNLBAMH": "0xCCA3780"
5.2 Output Format (PacketIds.json)
The resulting dictionary is serialized to formatted JSON:
{
"KOOKJNLBAMH": 101,
"MGDFLHGODLH": 102,
"CGKBCOJMLPC": 143,
"OOMIIJFHPFN": 172
}
6. The Complete 5-Stage Protocol Reverse-Engineering Toolchain
By studying LunarCore, March7thHoney, FireflyProtoc, honkai-dumper, Firefly-Static-Parser, and StaticCmdIdDumper, we can now construct the entire open-source reverse-engineering pipeline:
+----------------------------------------------------------------------------------------------------+
| THE UNIFIED PROTOCOL EXTRACTION PIPELINE |
+----------------------------------------------------------------------------------------------------+
| |
| [ Stage 1: Metadata Extraction ] |
| Tool: Firefly-Static-Parser (Offline) OR honkai-dumper (In-Process) |
| Input: GameAssembly.dll + global-metadata.dat (b"MHY\0") |
| Output: dump.cs (class schemas) + methods.json (method RVAs) |
| |
| [ Stage 2: Static Opcode Extraction ] |
| Tool: StaticCmdIdDumper |
| Input: Executable binary + method RVAs of getter functions |
| Process: Reads 4-byte machine code (66 B8 imm16) -> extracts uint16 CmdIds |
| Output: PacketIds.json (Map of ObfuscatedPacketName -> Integer CmdId) |
| |
| [ Stage 3: Protobuf Schema Annotation ] |
| Tool: Schema Annotator / String Literal Correlator |
| Action: Injects metadata annotations into .proto files: |
| meta uint32 cmd_id = 143; |
| uint32 id = 1 [get: (id ^ 1) + 2]; |
| |
| [ Stage 4: Specialized Protobuf Compilation ] |
| Tool: FireflyProtoc |
| Action: Compiles .proto via AST pipeline (HIR -> MIR -> LIR) |
| - ExpressionInverter: Automatically derives setter logic for wire masks |
| - MessageFactory: Emits O(1) compile-time jump table switch(cmdId) |
| Output: High-performance C# / Go protobuf stubs |
| |
| [ Stage 5: Server Protocol Routing ] |
| Tool: March7thHoney (.NET 10) / LunarCore (Java) / GaC-hkrpg-go (Go) |
| Action: Instant zero-reflection packet dispatching on KCP connections |
| |
+----------------------------------------------------------------------------------------------------+
7. Technical Critique & Improvements for GaC-hkrpg-go
While StaticCmdIdDumper is compact and effective, an industrial-grade implementation (e.g. ported to Go for GaC-hkrpg-go) requires resolving several architectural limitations:
7.1 Limitation 1: Hardcoded Section Name ("il2cpp")
In Misc.cs:
if (sections![idx].name != "il2cpp") continue;
- Issue: In Honkai: Star Rail and standard Unity builds, code is placed in the standard
.textsection, not a section namedil2cpp. - Solution: Implement dynamic RVA-to-Section resolution across all sections:
\text{Section} = \{ s \in \text{Sections} \mid s.\text{VirtualAddress} \le \text{RVA} < s.\text{VirtualAddress} + s.\text{VirtualSize} \}
7.2 Limitation 2: 32-Bit Register Emptiness (mov eax, imm32)
- Issue: If the method return type is
int(32-bit signed integer) rather thanushort, compilers emitmov eax, imm32without the0x66prefix:B8 xx xx 00 00(5 bytes:0xB8followed by 4-byte integer). - Solution: Support both instruction patterns:
66 B8 [imm16]\rightarrow16-bit opcode getter.B8 [imm32]\rightarrow32-bit opcode getter.
7.3 Limitation 3: Stream Seeking Overhead
- Issue: Opening and seeking
FileStreamindividually for every single RVA creates heavy I/O overhead for thousands of packets. - Solution: Memory-map the section or load the section into a contiguous
[]byteslice in memory for instantO(1)random access.
7.4 Go Port Blueprint for GaC-hkrpg-go
A native, high-performance Go implementation for extracting opcodes directly within the GaC-hkrpg-go toolset:
package pe_dumper
import (
"debug/pe"
"encoding/binary"
"fmt"
"os"
)
func ExtractCmdIdFromRVA(peFile *pe.File, rva uint32, f *os.File) (uint16, error) {
// 1. Locate the containing section dynamically
for _, sec := range peFile.Sections {
if rva >= sec.VirtualAddress && rva < sec.VirtualAddress+sec.VirtualSize {
fileOffset := int64(sec.Offset + (rva - sec.VirtualAddress))
buf := make([]byte, 4)
if _, err := f.ReadAt(buf, fileOffset); err != nil {
return 0, err
}
// 2. Pattern: 66 B8 [imm16]
if buf[0] == 0x66 && buf[1] == 0xB8 {
return binary.LittleEndian.Uint16(buf[2:4]), nil
}
// 3. Pattern: B8 [imm32] (32-bit fallback)
if buf[0] == 0xB8 {
return binary.LittleEndian.Uint16(buf[1:3]), nil
}
return 0, fmt.Errorf("unknown instruction pattern: % X", buf)
}
}
return 0, fmt.Errorf("RVA 0x%X not found in any section", rva)
}
8. Compliance with Repository Rules (AGENTS.md)
- Documentation & Protocol Integrity:
StaticCmdIdDumperextracts Command IDs purely from the machine code representation of the target client binary. It adheres strictly to the rule requiring handlers to resolveCmdIDfrom verified client protocol tables without guessing or hardcoding multi-version aliases. - No Machine-Specific Hardcoding: All file paths and offsets are read dynamically from command-line arguments and PE headers.
- Zero Runtime Interference: Operates entirely offline on the executable file, with zero memory injection, hooks, or heartbeat overhead.