01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
crc32(data, len)
>> 0x00FF: ACK
schedule(task, interval)
lock.acquire()
>> SYNC COMPLETE
release(ptr)
0x00 0x00 0x00 0x01
watchdog.reset()
>> LINK ESTABLISHED
fn poll(&mut self) -> Poll
waker.wake_by_ref()
cx.waker().clone()
01101001 01101110
fn init() -> Result<()>
for x in 0..buf.len()
load(addr, 0xFF)
sys.run(0x4A, flags)
if val > 0 { dispatch() }
>> 0x00: READY
loop { poll(); yield; }
stream.flush()
0xDEAD :: 0xBEEF
bind(sock, &addr, len)
pub fn connect(host: &str)
match state {
State::Init => boot(),
State::Run => tick(),
_ => halt(),
}
reg[0x3] = 0b11001010
clk.tick()
assert!(val != null)
>> SIGNAL RECEIVED
buf[i] ^= key[i % klen]
let n = read(fd, buf, 64)
while !done { step(); }
push(stack, frame)
0x7F :: OK
type Handler = fn(Ctx)
emit(Event::Data, payload)
select! { rx => handle(rx) }
spawn(async move { run() })
>> 0x01: PROCESSING
map.insert(k, v)
drain().collect::<Vec<_>>()
let _ = tx.send(msg)
timeout(Duration::ms(100))
>> CHECKSUM PASS
fn encode(src: &[u8]) -> Vec
pipe.write_all(&frame)
← JOURNAL
PUBLIC12 min read

The Wheel Is Alive: SW7C Configurator, HIDSniff, and the Hunt for Inverted FFB

sw7cdirect-drivepythonhidreverse-engineeringsim-racingusbvjoy

Last post I had a configurator that connected to the SW7C controller board and read back settings. The sliders populated with real values — End Stops 100%, Spring 100%, Damper 26.5% — which was satisfying proof that GetSettings worked.

The wheel base itself hadn't arrived yet. Now it has.


What connecting the actual base taught us

The first surprise was that turning the wheel produced no movement in Windows Game Controllers. Joy.cpl showed the device — listed as SW7 Compact, its registered HID name — but the axes were completely dead.

This turned out to be two separate problems, back to back.

Problem one: the configurator was grabbing the wrong thing. My initial connect code called hid.open(VID, PID), which opens whichever HID interface the system hands back first. I'd assumed the SW7C had multiple interfaces — a control interface for commands and a separate joystick interface for axis data — because the decompiled source referenced mi_00 and mi_02 paths. It doesn't. One USB HID interface, usage page 1, usage 4 — standard joystick. Everything goes through the same endpoint.

Knowing that, I updated the connect logic to enumerate interfaces explicitly and prefer mi_00 if it exists, falling back gracefully. On this device it doesn't matter because there's only one interface, but the enumeration is cleaner regardless.

Problem two: the firmware needs a kick. Once the interface issue was resolved, joy.cpl still showed nothing. The wheel was connected and responding to commands — GetSettings worked, GetDiagnosticData worked — but it wasn't generating joystick input reports.

The original SW7 Compact sends ApplyChanges on startup, immediately after reading the current settings. That's the trigger. Without it the firmware sits idle and doesn't output axis data. Once I added an auto-apply on connect — read settings, then immediately send them back — the axes came alive.

SW7C Configurator connected — Windows Game Controllers showing SW7Compact OK, axis properties visible

What's working now

Live wheel position. The GetDiagnosticData command (0xB2) returns the current wheel position as a signed int16, alongside the configured rotation range as a uint16. Degrees from centre: position × rotation / 65535. Polled at 50Hz for the vJoy output, with the UI arc updating every 12th tick so it doesn't flood the main thread with redraws.

The position display is a semicircular arc at the top of the Settings tab. Fixed 180-degree display regardless of your configured rotation — 90-degree wheel maps the same as a 1440-degree wheel, just with different end stop markers. The position spoke shifts from cyan to orange to red as you approach the end stop zone.

FFB Monitor. GetWheelFFBHistory (0xB7) returns five session entries — current plus four previous — with torque percentage, clipping percentage, duration and peak torque per session. One thing I learned from the decompiled source: you have to call GetWheelTelemetry (0xB6) first. The firmware won't respond to the FFB history request until telemetry has been primed. Once that ordering was fixed, the monitor populated.

Advanced parameters. USB Mode (Sim/Game/Console), Controller Bandwidth, Virtual Torque, Weighted Centre Range and Gain — all reading and writing correctly.

Profiles. JSON files per profile, last-used restores on launch, Save As lets you name them for specific cars or sims.


What's confirmed working

End stops are confirmed working. The encoding was correct all along — EndStops × 100 as a signed int16 at bytes 4–5 of ApplyChanges. The HIDSniff capture of SW7 Compact sending 10 27 (10000) for 100% end stops matches exactly what the configurator sends.

Damper, Friction and Spring have no noticeable effect on the desktop — these are FFB effect gain parameters, not standalone forces. They scale the magnitude of those effects when a game sends them via DirectInput.

Torque and Inertia are applied by the controller itself regardless of whether a game is running. Inertia in particular controls the feel of wheel weight — the rotational resistance you feel when turning. The configurator now groups these separately from the FFB gains.


Building a proper sniffer

The obvious next step was to run the original SW7 Compact alongside a USB capture tool and watch exactly what it sends, in what order, from cold start through to a live apply.

I could use Wireshark with USBPcap for this. But we're building an Indigo-Nx tooling family — DevScan, the X52 configurator, ViewShift — and a purpose-built HID sniffer fits naturally into that set. So I built one.

HIDSniff reads the USBPcap named pipe, parses the raw pcap stream and the USBPcap transfer headers, and decodes every HID interrupt transfer live. SW7C command bytes get decoded by name with full field breakdowns — so instead of seeing AB 84 03 10 27 E8 03..., you see ApplyChanges | Rotation=900deg | EndStops=100.0% | Torque=7.20Nm. Packets are colour-coded by direction: blue for host-to-wheel, purple for wheel-to-host. Toggle a filter to show only decoded SW7C protocol packets. Click any row for a full hex dump with field annotations.

HIDSniff showing decoded SW7C protocol packets with field breakdowns

Building it surfaced a few USBPcap gotchas. The capture binary requires administrator elevation. USBPcap's newer versions require the -A flag to capture all devices on a controller — it's no longer the default. USBPcap's -l listing flag is interactive and useless for programmatic enumeration.

The solution was to side-step USBPcap enumeration entirely. HIDSniff uses hidapi.enumerate() to list all connected HID devices, then probes which USBPcap controllers exist by briefly attempting a CreateFile call against each (USBPcap1 through USBPcap5). If USBPcap isn't installed, the app shows a download button. If not running as admin, it shows a RELAUNCH AS ADMIN button that re-invokes itself elevated via ShellExecuteW.


Getting the sniffer working — and breaking the wheel

Getting HIDSniff to capture traffic smoothly took a bit of work, and involved an accidental near-disaster.

The UI locked up completely once capture started. A direct drive wheel generates joystick position reports at roughly one per millisecond. The original code was scheduling a UI update callback for every single packet, flooding the Tkinter event queue. Fixed by batching: the capture worker appends to a pending list, a timer drains it every 100ms with a cap on inserts per tick. The UI stays responsive even at full capture rate.

Then I caused a problem. While testing the configurator with the live wheel, the SAVE TO WHEEL button got clicked with Torque at zero. SaveSettings writes to EEPROM. EEPROM survives power cycles.

On the next power cycle the wheel tried to calibrate — the firmware does an index search on startup, rotating slowly to find the encoder's index pulse — but with Torque=0 in EEPROM it couldn't generate any force. The motor hummed but didn't move. The wheel was stuck, indefinitely, making noise, going nowhere.


Reading the EEPROM directly

The first recovery script attempt used the wrong HID write format. HID reports for this device start with the report ID byte (0x5C) at position zero — not the null prefix that some implementations expect. Once corrected, the commands got through.

GetSettings confirmed the damage: Torque, EndStops, Damper, Friction, Inertia and Spring were all zero.

GetFailSafe was more useful — it returned the factory safe values the firmware falls back to in fault conditions: Torque 7.20 Nm, EndStops 100%, Inertia 2.5%. These became the recovery targets.

GetStatus returned State=3, EStop=True. The wheel has a broken E-Stop connector — missing when it arrived — so the E-Stop input is permanently open. The wheel still functions; it's cosmetic noise in the status byte.

The recovery: ApplyChanges with the factory values, then SaveSettings to commit to EEPROM, then a power cycle. Calibration completed. Wheel came online.

The read-eeprom and recovery scripts are now built into the configurator as a Tools tab. READ EEPROM queries GetSettings, GetEeprom, GetFailSafe and GetStatus and prints the decoded values to an output log. RECOVER TO FACTORY DEFAULTS sends the known-good values with a confirmation prompt.

SW7C Configurator Tools tab — Read EEPROM, Read Input Data, and Recover to Factory Defaults

What the capture told us

With the wheel healthy, HIDSniff captured SW7 Compact's full startup sequence: GetSettingsGetFailSafeGetEepromGetSettings again → GetInputData. Then a polling loop on GetStatus every few seconds. When you hit Apply it sends ApplyChanges, reads back with GetSettings, and resumes polling.

The ApplyChanges payload from SW7 Compact confirmed our encoding is correct. EndStops at 100% sends 10 27 (little-endian 10000) — the ×100 multiplier is right. Wire values match exactly.


vJoy output

The native HID joystick output from the SW7C was unreliable — axes would sometimes lock to centre on connect. Rather than continue fighting it, the configurator now outputs wheel position through vJoy instead.

The configurator polls GetDiagnosticData at 50Hz and maps the result to vJoy axis X — normalised from the rotation range into the 1–32768 vJoy scale with centre at 16384. The header shows live status: grey vJoy: OFF, green vJoy: #1 on connect, then vJoy: +127° updating in real time.

The longer term plan is device merging — wheel on vJoy device 1, pedals on vJoy device 2, combined into a single virtual controller. The building blocks are already there.


USB mode and the two personalities of the SW7C

One thing worth understanding before you change USB Mode settings: it is not just a software flag. Switching between Sim, Game and Console modes causes the wheel to physically re-enumerate on USB with a different Product ID. Sim mode is PID 0277. Game mode is C28F.

This means software that connects by VID/PID will lose the device when you switch. Windows sees a new USB device, re-enumerates, and only software that knows about both PIDs reconnects automatically.

The configurator didn't — it was looking for 0277 only. Once we saw the wheel disappear and reappear in DevScan as 1CBE:C28F, the fix was straightforward: enumerate both PIDs on every connect attempt. The reconnect loop now finds the wheel within two seconds regardless of mode.

There's a second difference: in Sim mode the wheel exposes a separate control interface (mi_00) alongside the joystick interface. In Game mode there's only one interface — the joystick. The configurator's fallback previously fell through to a broken hard-coded open call. Now if no preferred interface is found but a device is present, it takes whatever path is available.


The FFB Monitor — confirmed live in-sim

The FFB Monitor tab was built before the wheel base arrived. With a game actually running FFB, it works.

With CarX Drift Racing Online running, the monitor showed Peak Torque at 7.1%, Clipping at 0.4%, Duration 1 minute, and four history sessions all populated. The data comes from GetWheelFFBHistory (0xB7) — five session entries per call, each with torque, clipping, peak and duration. You can see at a glance whether your FFB settings are pushing the wheel into clipping territory.

This is aggregate session data, not a real-time signal trace. The bars don't update continuously — they reflect what the firmware has accumulated since the session started. Refresh manually or enable Auto to poll every second.

SW7C Configurator FFB Monitor tab with live data from CarX — Peak Torque 7.1%, Clipping 0.4%, 1 minute duration

Digging into FFB inversion — and what we learned about CarX

The FFB behaviour in CarX was inverted — turn right, the force pushes right instead of pushing back. On a direct drive wheel with 7Nm of torque, that's not subtle.

The obvious candidate was USB Mode. Game mode is supposed to fix inverted FFB in newer titles. We tested it — the wheel reconnected as C28F, CarX picked it up — but the feel was still wrong.

So we dug into the CarX config file, which lives in %APPDATA%/../LocalLow/CarX Technologies/. It's a JSON file with a per-device entry for every controller that's ever been connected, each with a disableFFB flag and per-axis invert flags.

What we found: the SW7 Compact entry in Sim mode had disableFFB: True — CarX had disabled FFB on it at some point, probably during an earlier failed enumeration. The invert flag looked like the fix for the direction problem, but it only affects the input axis direction (steering left/right), not the force feedback output direction. Those are different things.

GetInputData (0xB8) — a firmware command from the decompiled source that looked like it might carry an invert flag — returns no response on this firmware revision.

So FFB inversion is unresolved. The clean solution is vJoy FFB forwarding: intercept FFB effects as CarX sends them to the vJoy virtual device, negate the direction, and forward them to the real wheel over HID. That's a separate post.

For now: the wheel has input, the configurator has full control, the FFB Monitor is live, and we know exactly where the remaining work is.


What comes after

The configurator is genuinely complete for configuration — settings, persistence, live position tracking, FFB monitoring, profiles, system tray, recovery tools. Everything the original software did, plus the things it didn't.

What's left: FFB inversion via vJoy forwarding, a .exe build with PyInstaller so it runs without a Python install, and eventually a download page on the site. There are people in sim racing forums right now asking where to find the original SW7 Compact software. This should be the answer they find instead.

HIDSniff ships alongside it as a standalone tool — useful for any HID reverse engineering work, not just the SW7C.

// FEEDBACK

LEAVE A COMMENT

← BACK TO JOURNAL