Playing with KAT Walk C2. (Part 1: playing actually)
I have an odd habit: I play computer games. However, the definition of “play games” is quite odd.
For a couple of years I own a VR Treadmill from KAT VR.
According to its statistics, I’ve walked on it about 30km and made about 40k steps. That’s kind of a lie, as I played for way more…
The truth is: I’ve played with it more than on it. However, first things first.
What is a VR Treadmill
VR treadmill is a way to stay inside of a small play area but to explore the large virtual world, like, walking in place. There are few solutions available on the market for “VR locomotion”: from the simplest techniques “walk till the border, turn in real, turn in VR, continue”, thru “run seated" solutions to VR Treadmills.
There are several VR Treadmill solutions as well: there is a large class of treadmills that moves the player to negate its motion, like HoloTile, or one that was shown in Mythic Quest which looks like a normal treadmill but with bars, or even motorized shoes FreeAim. The second class of VR Treadmills ties the player in place. My treadmill is one of the latter. Generally speaking, that’s the only available on the market which is both, ready-to-use and with bearable price.
More precisely, to my knowledge, the whole market has KAT Walk C2 / C2Core... And that’s it.
The second alternative is Virtuix Omni One, which is now close to its mass-production phase. Still, so far only a few units have been delivered to their early bakers in the US. This means we can discuss the pros and cons between KAT Walk and Omni One, but it’s premature.
KAT Walk C2 / C2Core treadmill
This VR Treadmill is not a treadmill, but a kind of platform/dish. Over that dish freely rotates a backplate attached to a strong bearing and the player secures themselves with a belt to it. The belt itself has the freedom to move up and down on a backplate, which gives the player the freedom to crouch or jump a little. My version, C2, has limited vertical freedom, so I have to choose between crouching and jumping. Since my play style is “sniff at every corner”, I chose the former. The C2 Core model provides users with larger vertical freedom, and doesn’t need to be adjusted for player height, but as a downside players can’t “float” in the air hanging on a belt. Although, I don’t have games that can utilize this feature anyway.
Okay, the belt is a good and secure, what’s next? Next — special boots, holding sensors. Previous Kat Walk generation (KAT Walk C) used IMU sensors, but C2/C2Core uses optical mouse sensors. Plus third sensor is installed inside of the backplate, which provides a body direction signal.
All three sensors require charging, provided over a special magnetic cable from a box installed under the dish. On fully charged sensors one can play for about 6–7 hours, which is more than the average player’s motion sickness tolerance :) Or, your headset will run out of juice, if you are lucky.
The box under the dish works as a wireless receiver, connecting to the sensors and transmitting the data over a USB cable to the PC. You should install and run the “KAT Gateway” application, which communicates with the platform and allows you to play PCVR games. Supported both Oculus and Steam VR games, although support levels vary. From game’s perspective, movement on a platform is just a joystick push to the degree proportional to speed and distance walked, and the push angle is proportional to the difference between body direction (obtained from the backplate sensor) and view direction (obtained from a headset). That means it works with almost any game with “locomotion towards look direction”.
VR Treadmill and Motion sickness
May VR players learned that play sessions are typically limited not by headset battery, but more like tolerance battery in vestibular apparatus. My first attempts to play HL Alyx (before I bought the treadmill) were over in about ~3 total play hours, with most of the sessions being way under 20 minutes. I wasn’t able to tolerate movement seated. Even with the vignette enabled, using the teleport movement… I even tried to move while walking in place — not really help me.
With KAT Walk C2 I can play for up to 1.5–2 hours, but that still depends on the particular game, on how natural movement feels in the particular game. One can try to make it better by adjusting max speed, acceleration, adjusting height from the floor etc, but that could make things not only better but rather much worse.
While I used still Quest 2, migration to 120Hz helped a lot. Upgrade to Quest 3 helped even more with Wifi 6 support. My current room setup includes a PC connected by LAN cable to TP-Link RE705X, which in turn connected with OpenMesh to central Zyxel. Despite being connected to uplink over wireless, since I play same room, Virtual Desktop shows a full-bandwidth connection with 2404 MBps to my PC.
For even more comfort and lower interference, there are also direct wifi adapters on the market, like D-Link VR Air Bridge.
The main point here: VR Treadmill is not a panacea, but it definitely makes things better.
Playing on a platform
So, the treadmill is not a treadmill but a sliding dish. While it is advertised as “natural”, it is not really. You are supposed not to “walk” on it, but rather to “slide” in the dish. The most accurate description of walking there would be “pulling a massive weight with a belt through water”: you lean forward, you push the legs under you backward, and they move back — you move forward in a game. Our minds adapt quite fast and the vestibular apparatus gets quite happy with the feelings you get. If you need to strafe in a game — you move one of the legs sideways and lightly slide over a dish, standing all the time on another leg. During the intense gaming scenes, you can still use the thumbsticks as usual. You don’t actually have to do that in PvE games, but in PvP it is really hard to play without.
The actual movement on a platform is more intense than walking, so even without a tactical vest I start sweating in 15 minutes or so, even though most games are not designed for active walking, so if you get about 30% of playtime walking — that’s a good ratio. For example, HL Alyx, especially when you start to check every corner you can, is exactly about 30%.
Little better ratio in Talos Principle VR. If you don’t stop to look for a view around (which you actually should do — the game is just worth getting it for walking around!), then you get about 50% of active walking. Especially, if you still remember the solution for the puzzle (unfortunately, the non-VR version of Talos Principle I completed too recently, less than 10 years ago…)
Another example of a great walking sim would be
- No Man’s Sky (although the game has an issue — the HUD is fixed in one real-world direction position, which breaks treadmill playing)
- Syrim (especially with FUS mods, but I am not a very good advisor on mods there).
- Some players on Discord channel recommended also Borderlands 2 VR, in an aggressive mode, when you do your best to run through the zone without stopping or fighting, which makes it a separate challenge itself.
Of course, games like Pavlov, Zero Caliber, and Contractors are great options for all shooter players — both in PvE and PvP. However, in PvP you’ll have to rely more on your tactic and shooting skill since your mobility is a little reduced comparing to thumbstick players.
The compatible games list on KAT site is not exhaustive, as many games with 3rd party VR mods working just fine. For example, Firewatch with this VR mod is perfectly playable and enjoyable with great immersion into the story — and a good walking percentage.
Playing with a platform
So, playing with a ready-to-use is not enough fun, so look for extra fun while headset charging. Let’s start with playing with an SDK.
The SDK is provided in a large archive containing bindings for Unreal, Unity, some assets and other stuff that game developers are interested in. I am not a game developer, so I’ll go deeper. Inside of the KATVRUniversalSDK/Source/KATVRUniversalSDK
folder, one can find headers with API to KATNativeSDK.dll
.
By reading KATSDKWrapper.h
we discover the most interesting functions:
int DeviceCount()
— that just returns the number of connected treadmills;KATDeviceDesc GetDevicesDesc(unsigned index)
— description of the connected treadmill by index. The description includes a serial number, the name of the device, VID/PID of the USB device and “type” of the device;KATTreadMillMemoryData GetWalkStatus(const char* sn)
— actual function to get a snapshot of the state.
The rest is not interesting, and from these three GetWalkStatus
is the most interesting:
struct DeviceData
{
bool btnPressed;
bool isBatteryCharging;
float batteryLevel;
char firmwareVersion;
};
struct TreadMillData
{
char deviceName[64];
bool connected;
double lastUpdateTimePoint;
Quaternion bodyRotationRaw;
Vector3 moveSpeed;
};
struct KATTreadMillMemoryData
{
TreadMillData treadMillData;
DeviceData deviceDatas[3];
char extraData[128];
};
So, we have the moment of the last refresh and the data itself, which includes connection status, quaternion of body direction and calculated movement speed. Hm… That’s definitely not all of the data. Where are the speeds of each leg? And an eye direction? Gateway shows all that:
Never mind, we’ll look into that later. Let’s play with what we have right now. So, what do we want? Let’s read everything we can, without the external dependencies (well, still using KATNativeSDK.dll
, at least, for now).
Let’s create in Visual Studio console application, pull structures definitions out of the header (don’t forget to copy the #pragma pack(push,1)
/ #pragma pack(pop)
of course), and keep function types only for relevant functions:
using deviceCountFunc = int(void);
using getDevicesDescFunc = KATDeviceDesc(unsigned);
using getWalkStatusFunc = KATTreadMillMemoryData(const char*);
const char* const sKatDeviceType(int deviceType)
{
switch (deviceType) {
case 0: return "ERR";
case 1: return "Treadmil";
case 2: return "Tracker";
default: return "?";
}
}
Then write a sample:
int main()
{
HMODULE hKatDll = LoadLibraryW(L"KATNativeSDK.dll");
if (!hKatDll || hKatDll == INVALID_HANDLE_VALUE) {
std::cout << "Whoops, World!\n";
exit(1);
}
std::cout << "DLL loaded!\n";
std::function<deviceCountFunc> deviceCount = reinterpret_cast<deviceCountFunc*>(GetProcAddress(hKatDll, "DeviceCount"));
if (!deviceCount) {
std::cout << "Whoops, <DeviceCount> Not Found\n";
exit(1);
}
std::function<getDevicesDescFunc> getDevicesDesc = reinterpret_cast<getDevicesDescFunc*>(GetProcAddress(hKatDll, "GetDevicesDesc"));
if (!getDevicesDesc) {
std::cout << "Whoops, <GetDevicesDesc> Not Found\n";
exit(1);
}
std::function<getWalkStatusFunc> getWalkStatus = reinterpret_cast<getWalkStatusFunc*>(GetProcAddress(hKatDll, "GetWalkStatus"));
if (!getWalkStatus) {
std::cout << "Whoops, <getWalkStatusFunc> Not Found\n";
exit(1);
}
int count = deviceCount();
std::cout << "Kat Devices found: " << count << "\n";
for (int i = 0; i < count; ++i) {
std::cout << "== Kat Device #" << i << "\n";
KATDeviceDesc desc = getDevicesDesc(i);
std::cout << " Name: " << desc.device << "\n";
std::cout << " S/N : " << desc.serialNumber << "\n";
std::cout << " ID : " << std::hex << desc.pid << ":" << desc.vid << "\n";
std::cout << " Type: " << desc.deviceType << "(" << sKatDeviceType(desc.deviceType) << ")\n";
}
KATTreadMillMemoryData data = getWalkStatus(nullptr);
std::cout << "Device: " << data.treadMillData.deviceName << (data.treadMillData.connected ? " " : " not ") << "connected" << "\n";
std::cout << "== Kat Walk Status:\n";
for (int i = 0; i < 3; ++i)
{
std::cout << "Dev" << i << ": Battery: " << data.deviceDatas[0].batteryLevel << "; "
<< "Btn: " << data.deviceDatas[0].btnPressed << "; "
<< "firmware: " << (int)data.deviceDatas[0].firmwareVersion << "; "
<< "charging: " << data.deviceDatas[0].isBatteryCharging << "\n";
}
std::cout << "Rotation: ("
<< data.treadMillData.bodyRotationRaw.x << ", "
<< data.treadMillData.bodyRotationRaw.y << ", "
<< data.treadMillData.bodyRotationRaw.z << ", "
<< data.treadMillData.bodyRotationRaw.w << ")\n";
std::cout << "Move speed: ("
<< data.treadMillData.moveSpeed.x << ", "
<< data.treadMillData.moveSpeed.y << ", "
<< data.treadMillData.moveSpeed.z << ")\n";
return 0;
}
Nice, we’ve got raw data. What we can do with it?.. Let’s turn quaternion into yaw angle, similar to what KAT Gateway does. So, we have a qart, we need a yaw… Let’s look at how Gateway do it.
Downloading JetBrains dotPeek and load KAT Gateway.exe
. Searching thru is there anything about “angle”? Nope, okay, let’s look into the `Program` section:
// ...
switch (ComUtility.KATDevice)
{
case ComUtility.KATDeviceType.loco:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.loco));
break;
case ComUtility.KATDeviceType.loco_s:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.loco_s));
break;
case ComUtility.KATDeviceType.walk_c:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c));
break;
case ComUtility.KATDeviceType.walk_c2:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c2));
break;
case ComUtility.KATDeviceType.mini:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.mini));
break;
case ComUtility.KATDeviceType.walk_c2_core:
Application.Run((Form) new Home_Form(showMode, ComUtility.KATDeviceType.walk_c2_core));
break;
}
Oh, okay, let’s look into Home_Form
now, what’s in the constructor?
public Home_Form(bool show, ComUtility.KATDeviceType kATDeviceType)
{
// ... bla bla bla ...
this.Open_Home_Form_Walk_C_Center_Left(kATDeviceType); // ahha?
// ... bla bla bla ...
}
private void Open_Home_Form_Walk_C_Center_Left(ComUtility.KATDeviceType kATDeviceType)
{
switch (kATDeviceType)
{
case ComUtility.KATDeviceType.loco:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_Loco_Dx.Home_Form_Loco_Main,KAT_Loco_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
case ComUtility.KATDeviceType.loco_s:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_LocoS_Dx.Home_Form_Loco_S_Main,KAT_LocoS_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
case ComUtility.KATDeviceType.walk_c:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC_Dx.Home_Form_Walk_C_Main,KAT_WalkC_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
case ComUtility.KATDeviceType.walk_c2:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC2_Dx.Home_Form_Walk_C2_Main,KAT_WalkC2_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
case ComUtility.KATDeviceType.mini:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_Mini_Dx.Home_Form_Mini_Main,KAT_Mini_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
case ComUtility.KATDeviceType.walk_c2_core:
this.OpenForm(new PluginHelper().LoadPlugInForm((Form) this, "KAT_WalkC2_Dx.Home_Form_Walk_C2_Main,KAT_WalkC2_Dx.dll", ParameterType.Home, (Dictionary<string, object>) null, true), this.panel_Center_Left);
break;
}
}
Great. Let’s load KAT_WalkC2_Dx.dll
then and look into its Home_Form_Walk_C2_Main
:
// ...
private Label label_HMD;
private Label label_HMD_Title;
private Label label_Waist_Title;
private Label label_Left_Foot_Title;
private Label label_Right_Foot_Title;
private ToolTip toolTip;
private Panel button_haptic;
private Panel panel_Tracker;
private System.Windows.Forms.Timer timerGDIWalkPaint;
private Panel panel_LED;
private Panel panel1;
private Label label_Waist;
// ...
Yup! That’s it. So, the angle called “waist”, sure, let’s right-click on label_Waist
and then “Find Usages”:
// ...
if (KATSDKInterfaceHelper.Receiver_status == 0 || KATSDKInterfaceHelper.Compass_status == 0)
this.label_Waist.Text = "0°";
else
this.label_Waist.Text = ((double) MotionDriver.Angle < 360.0 ? ((double) MotionDriver.Angle >= 0.0 ? Convert.ToInt32(MotionDriver.Angle) : Convert.ToInt32(MotionDriver.Angle + 360f)) : Convert.ToInt32(MotionDriver.Angle - 360f)).ToString() + "°";
Sure, makes sense, what’s about MotionDriver.Angle
?
MotionDriver.Angle = KATSDKInterfaceHelper.yawCorrection + Convert.ToSingle(num2);
Perfect, that’s it! We also just learned how the “Calibration” button works — by saving the required delta into yawCorrection
. Neat.
So, how we get the angle itself? Let’s scroll up a bit…
KATSDKInterfaceHelper.Quat q = new KATSDKInterfaceHelper.Quat();
q.w = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.w;
q.x = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.x;
q.y = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.y;
q.z = Home_Form_Walk_C2_Main.objTreadMillData.bodyRotationRaw.z;
double num2 = (double) KATSDKInterfaceHelper.EulerAngles(q).y / Math.PI * 180.0;
Not a surprise. Using file search find out that KATSDKInterfaceHelper
is part of IBizLibrary
, so loading it into dotPeek as well.
Now, looking at EulerAngles
:
public static KATSDKInterfaceHelper.Vector3 EulerAngles(KATSDKInterfaceHelper.Quat q)
{
KATSDKInterfaceHelper.Vector3 vector3 = new KATSDKInterfaceHelper.Vector3();
float num1 = q.w * q.w;
double num2 = (double) q.x * (double) q.x;
float num3 = q.y * q.y;
float num4 = q.z * q.z;
double num5 = (double) num3;
float num6 = (float) (num2 + num5) + num4 + num1;
float num7 = (float) ((double) q.x * (double) q.w - (double) q.y * (double) q.z);
if ((double) num7 > 0.49950000643730164 * (double) num6)
{
vector3.y = 2f * (float) Math.Atan2((double) q.y, (double) q.x);
vector3.x = 1.57079637f;
vector3.z = 0.0f;
return vector3;
}
if ((double) num7 < -0.49950000643730164 * (double) num6)
{
vector3.y = -2f * (float) Math.Atan2((double) q.y, (double) q.x);
vector3.x = -1.57079637f;
vector3.z = 0.0f;
return vector3;
}
KATSDKInterfaceHelper.Quat quat = new KATSDKInterfaceHelper.Quat(q.y, q.w, q.z, q.x);
vector3.y = (float) Math.Atan2(2.0 * (double) quat.x * (double) quat.w + 2.0 * (double) quat.y * (double) quat.z, 1.0 - 2.0 * ((double) quat.z * (double) quat.z + (double) quat.w * (double) quat.w));
vector3.x = (float) Math.Asin(2.0 * ((double) quat.x * (double) quat.z - (double) quat.w * (double) quat.y));
vector3.z = (float) Math.Atan2(2.0 * (double) quat.x * (double) quat.y + 2.0 * (double) quat.z * (double) quat.w, 1.0 - 2.0 * ((double) quat.y * (double) quat.y + (double) quat.z * (double) quat.z));
return vector3;
}
Looks interesting, but a little too verbose, since we only want a yaw (y). Also, experiments show that the data we get from SDK are normalized.
Quick googling for quaternion libraries gives much simpler variants, which we can boil down to:
struct Quaternion
{
//...
static Quaternion StraightUp() {
const float deg90 = (float)M_PI_4;
const float s = sin(deg90);
const float c = sin(deg90);
return { s * 0.0f, s * -1.0f, s * 0.0f, c };
}
float getRawAngle()
{
return 2.f * acos(this->w);
}
float getAngle()
{
Quaternion angle = Quaternion::StraightUp() * (*this);
return angle.getRawAngle();
}
By inlining everything together and heavily minimizing it, we get down to small and neat working for our normalized form:
float getAngle()
{
return (float)(2.f * acos(M_SQRT1_2 * (w + y)));
}
Adding a few extra lines with a loop into our sample, then turn the platform around — yup, working. But… it is not satisfying.
Maybe it’ll be more fun to re-implement it in python?
import ctypes
...
katsdk = ctypes.windll.LoadLibrary(os.getcwd()+os.sep+"KATNativeSDK.dll") # place the dll near the script
devco = katsdk.DeviceCount()
print(f"SDK reports {devco} devies connected")
...
class KATDeviceDesc(ctypes.Structure):
_pack_ = 1
_fields_ = [
("device", ctypes.c_char * 64),
("serialNumber", ctypes.c_char * 13),
("pid", ctypes.c_int32),
("vid", ctypes.c_int32),
("deviceType", ctypes.c_int32)
]
katsdk.GetDevicesDesc.restype = KATDeviceDesc
for k in range(0, devco):
print(f"\nSDK request for device # {k}:")
desc = katsdk.GetDevicesDesc(k)
print(f" Name: {desc.device}")
print(f" S/N : {desc.serialNumber}")
print(f" ID : {hex(desc.pid)}:{hex(desc.vid)}")
print(f" Type: {katDevType(desc.deviceType)}")
...
class KATTreadMillMemoryData(ctypes.Structure):
_pack_ = 1
_fields_ = [
("treadMillData", TreadMillData),
("deviceDatas", DeviceData * 3),
("extraData", ctypes.c_byte * 128)
]
katsdk.GetWalkStatus.restype = KATTreadMillMemoryData
katsdk.GetWalkStatus.argtypes = [ctypes.c_char_p]
walk = katsdk.GetWalkStatus(None)
print("Walk data:")
print(f" Device: {walk.treadMillData.deviceName}, {walk.treadMillData.connected and "" or " not "}connected")
print(f" Sensors:")
for k in range(0, 3):
...
Hmm… Nope. Not any better. Just plain copy-paste. Yes, automatable, but still not fun.
Let’s play a game with a game, No Man’s Play with Treadmill
Perhaps, we should play games, so let’s play games. So, pick a game and make it better!
I chose “No Man’s Sky” since my attempts to play it failed once I hit “What should I do here?”. The problem was caused by HUD that was behind me, so I haven’t seen it. It doesn’t turn with the head nor with the body! So you need to turn around to learn what to do, or reset view…
That’s not a way to play. Let’s fix it.
What do we have? We have a game that just needs a little nudge in the right direction. Downloading IDA Freeware, loading NMS.exe
. Ugh, that’s unreadable. Ah, right, we have a Steam version of the game. Let’s download steamless, unpack the game, and load into IDA NMS.exe.unpacked.exe
.
That’s better, but we need to start with something. Let’s try search with something reasonable, like, “HMD”:
.rdata:00000001428E7310 48 4D 44 5F 52 65+aHmdRecenter db 'HMD_Recenter',0 ; DATA XREF: .data:0000000142D56358↓o
.rdata:00000001428E731D 00 00 00 align 20h
.rdata:00000001428E7320 48 4D 44 5F 52 65+aHmdRecenter2 db 'HMD_Recenter2',0 ; DATA XREF: .data:0000000142D56368↓o
.rdata:00000001428E732E 00 00 align 10h
.rdata:00000001428E7330 48 4D 44 5F 46 45+aHmdFeopen db 'HMD_FEOpen',0 ; DATA XREF: .data:0000000142D56378↓o
Promising, but no references to these. More?
.rdata:00000001428EB278 55 73 65 50 6C 61+aUseplayercamer db 'UsePlayerCameraInHmd',0
.rdata:00000001428EB278 79 65 72 43 61 6D+ ; DATA XREF: sub_14151D400+31E↑o
.rdata:00000001428EB278 65 72 61 49 6E 48+ ; sub_14151F190+5E9↑o ...
.rdata:00000001428EB28D 00 00 00 align 10h
.rdata:00000001428EB290 41 6C 69 67 6E 55+aAlignuitocamer db 'AlignUIToCameraInHmd',0
.rdata:00000001428EB290 49 54 6F 43 61 6D+ ; DATA XREF: sub_14151D400+334↑o
.rdata:00000001428EB290 65 72 61 49 6E 48+ ; sub_14151F190+63A↑o ...
.rdata:00000001428EB2A5 00 00 00 align 8
.rdata:00000001428EB2A8 55 73 65 53 65 6E+aUsesensiblecam db 'UseSensibleCameraFocusNodeIsNowOffsetNode',0
.rdata:00000001428EB2A8 73 69 62 6C 65 43+ ; DATA XREF: sub_14151D400+34A↑o
.rdata:00000001428EB2A8 61 6D 65 72 61 46+ ; sub_14151F190+68B↑o ...
.rdata:00000001428EB2D2 00 00 00 00 00 00 align 8
Ahha, that’s better! Let’s backtrace references.
// ...
sub_1414834D0(a1 + 128, a2, "FocusInterpTime");
sub_1414834D0(a1 + 132, a2, "BlendInTime");
sub_1414834D0(a1 + 136, a2, "BlendInOffset");
sub_1414A7FF0(a1 + 144, a2, "Anim");
sub_1414834D0(a1 + 160, a2, "HeightOffset");
sub_14148DDD0(a1 + 164, a2, "UsePlayerCameraInHmd");
sub_14148DDD0(a1 + 165, a2, "AlignUIToCameraInHmd");
sub_14148DDD0(a1 + 166, a2, "UseSensibleCameraFocusNodeIsNowOffsetNode");
return sub_14148DDD0(a1 + 167, a2, "LookForFocusInMasterModel");
// ...
Perfect. That looks like the place to load or save settings, not relevant, but good to start unrolling this ball!
Keep backtracing usages and cross-references, ahha, we hit a nice array:
.rdata:00000001428D2E38 10 E4 03 40 01 00+ dq offset sub_14003E410
.rdata:00000001428D2E40 D0 DA 03 40 01 00+ dq offset sub_14003DAD0
.rdata:00000001428D2E48 40 E4 03 40 01 00+ dq offset sub_14003E440
.rdata:00000001428D2E50 50 DB 03 40 01 00+ dq offset sub_14003DB50
.rdata:00000001428D2E58 30 E5 03 40 01 00+ dq offset sub_14003E530
.rdata:00000001428D2E60 D0 DD 03 40 01 00+ dq offset sub_14003DDD0
.rdata:00000001428D2E68 60 E5 03 40 01 00+ dq offset sub_14003E560
.rdata:00000001428D2E70 50 DE 03 40 01 00+ dq offset sub_14003DE50
.rdata:00000001428D2E78 90 E5 03 40 01 00+ dq offset sub_14003E590
with a lot of tasty things referenced, like:
__int64 sub_14003E410()
{
return sub_142512100("cTkGlobals", sub_141535BB0, sub_141533E70, sub_1415355A0);
}
This is a good point to automate and mark a lot of the database with type names, function names and methods. Good if you want to understand the core, but we looking for something simple, something trivial.
So, let’s think. What do we know? We can turn in the game by wiggling the thumbstick left-right. The HUD remains visible when you do so, and the virtual body turns as well against the world. The settings also have two options — Snap and Smooth. What if we use treadmill turn to do snap/smooth turn this way?
Let’s search thru code with “Snap”, and references to Snap-related stuff, we find a huge function with quite an interesting part:
v39 = fastCos(v28.m128_f32[0]);
v40 = fastSin(v28.m128_f32[0]);
v41 = _mm_xor_ps(v40, (__m128)0x80000000);
v42 = _mm_unpacklo_ps(_mm_unpacklo_ps(v41, v39), (__m128)xmmword_142B2B830);
v43 = _mm_unpacklo_ps(_mm_unpacklo_ps(v39, v40), (__m128)xmmword_142B2B830);
sub_140178B20(&v647);
v647 = _mm_add_ps(
_mm_add_ps(
_mm_mul_ps(*(__m128 *)(a1 + 21264), _mm_shuffle_ps(v43, v43, 0)),
_mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
_mm_mul_ps(*(__m128 *)(a1 + 21296), _mm_shuffle_ps(v43, v43, 170)));
v648 = _mm_add_ps(
_mm_add_ps(
_mm_mul_ps(*(__m128 *)(a1 + 21264), (__m128)0i64),
_mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)xmmword_142B2CE10)),
_mm_mul_ps(*(__m128 *)(a1 + 21296), (__m128)0i64));
v649 = _mm_add_ps(
_mm_add_ps(
_mm_mul_ps(*(__m128 *)(a1 + 21264), _mm_shuffle_ps(v41, v41, 0)),
_mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
_mm_mul_ps(*(__m128 *)(a1 + 21296), _mm_shuffle_ps(v42, v42, 170)));
v650 = _mm_add_ps(
_mm_add_ps(
_mm_add_ps(
_mm_mul_ps(*(__m128 *)(a1 + 21264), (__m128)0i64),
_mm_mul_ps(*(__m128 *)(a1 + 21280), (__m128)0i64)),
_mm_mul_ps(*(__m128 *)(a1 + 21296), (__m128)0i64)),
*(__m128 *)(a1 + 21312));
*(__m128 *)(a1 + 21264) = v647;
*(__m128 *)(a1 + 21280) = v648;
*(__m128 *)(a1 + 21296) = v649;
*(__m128 *)(a1 + 21312) = v650;
sub_14133C550((char *)qword_144B28508 + 10437728, "SNAPTURN");
v44 = 0i64;
*((_BYTE *)qword_144B28508 + 9046820) = 1;
v45 = (__int64 *)qword_144B28508;
statHash = 0i64;
do
{
v46 = aVrSnapturns[v44];
*((_BYTE *)&statHash + v44) = v46;
if ( (unsigned __int8)(v46 - 97) <= 0x19u )
*((_BYTE *)&statHash + v44) = v46 - 32;
++v44;
}
while ( v44 < 0xD );
*(_WORD *)((char *)&statHash + 13) = 0;
HIBYTE(statHash) = 0;
gameStats(v45 + 235493, &statHash, 1, 0, 0i64);
}
}
We have sin+cos nearby, matrix operations — quite a typical picture for vectors & matrix calculus. If we scroll up more (why, why in the past I ignored decompilation and only read disassembly?):
v13 = dword_1432AC404;
if ( sub_1404B12C0(*((_QWORD *)qword_144B28508 + 1304414)) )
v13 = dword_1432AC408;
*(_BYTE *)(a1 + 12972) = 0;
v14 = *(__int64 **)(a1 + 424);
turnRadInput = *(float *)&v13 * 0.017453292;
v16 = *v14;
turnRad = turnRadInput;
if ( (*(unsigned __int8 (__fastcall **)(__int64 *, __int64, __int64))(v16 + 8))(v14, 155i64, 1i64) )
*(_BYTE *)(a1 + 12972) = 1;
if ( (*(unsigned __int8 (__fastcall **)(_QWORD, __int64, __int64))(**(_QWORD **)(a1 + 424) + 8i64))(
*(_QWORD *)(a1 + 424),
156i64,
1i64) )
{
turnRadInput = -turnRadInput;
*(_BYTE *)(a1 + 12972) = 1;
turnRad = turnRadInput;
}
if ( *(_BYTE *)(a1 + 12972) )
{
if ( dword_144626580 > *(_DWORD *)(*(_QWORD *)NtCurrentTeb()->ThreadLocalStoragePointer + 152i64) )
{
Init_thread_header(&dword_144626580);
if ( dword_144626580 == -1 )
{
sub_1401E33D0(stru_144623A40);
Init_thread_footer(&dword_144626580);
}
}
v17 = (__m128 *)GetPosition(a1, v675);
The code looks like something we would like to have: by calling an external function with two options (155 and 156, snap-left and snap-right) the decisions is made to use a constant or inverse sign constant. If we scroll even more above — we’ll see a similar piece of code (ugh, heavy inlining!), where the argument to sin/cos is the read of some external parameter, not a constant like that. That altogether looks like the smooth turn and the snap turn handlers.
Great, we know where user input is handled and immediately acted on, ideal place to intervene. What do we have now:
- Press left? => set flag true
- Press right? => set flag true, change sign of constant
- No flag? Not a turn, go to another branch.
So we can change the last bit:
- No flag? Let’s call out our code.
- Does our code return zero? => Not a turn, go to another branch.
So we need to make our code to:
- Read the last turn angle from the platform,
- Return delta to last known angle.
Something like that:
static float old = 0;
KATTreadMillMemoryData newdata = getWalkStatus(nullptr);
float angle = 2.0 * acos(newdata.bodyRotationRaw.w);
float diff = angle - old;
if (abs(diff) > 0.001) {
xmm7 = diff;
old = angle;
jmp $do_turn$;
}
jmp handle_turn_no_turn;
So we need to inject this code into process memory, which also uses Steam DRM…
That’s where Reloaded-II comes into play! Perfect tool, that provides a stable framework to patch, process steam and other stuff, keeping us busy with only interesting.
Although, it requires coding in C#, so let’s convert our SDK bindings into C#. Again, pure mechanical transcription one into another, just keep in mind to use Reloaded-II wrappers for compatibility.
First complexity we meet: we want to get the angle in particular xmmX register. Well, we can just move this logic into the injection point and let our hook function put the angle into a global variable and just return bool
, that would be:
private static unsafe float* _angleDiff = (float*)Marshal.AllocHGlobal(sizeof(float));
const float M_2PI = (float)(2.0 * Math.PI);
private static float _lastAngle = 0.0f;
private static byte _GetTurnAngleDiff()
{
KATTreadMillMemoryData data;
GetWalkStatusWrapper!(out data, 0);
float angle = data.bodyRotationRaw.GetAngle();
if (Math.Abs(angle - _lastAngle) > 0.0001)
{
float diff = _lastAngle - angle;
if (diff > Math.PI) diff -= M_2PI;
else if (diff < -Math.PI) diff += M_2PI;
unsafe { *_angleDiff = diff; }
_lastAngle += diff;
if (_lastAngle < 0) _lastAngle += M_2PI;
else if (_lastAngle > M_2PI) _lastAngle -= M_2PI;
return 1;
}
return 0;
}
Since we use floats and deltas, we may start accumulating an error over time. To avoid that, we do not store the last known value, but instead, we integrate the deltas we return to the game. That way if there is any rounding error, it will self-correct eventually.
Okay, the boring math part is done, let’s plain into code injection. Reloaded-II has tools for injections, enabling and disabling patches, effective memory search, the ability to inject multiple plugins in parallel and so on. While that’s great, the project evolves a little faster than its documentation (as usual) and I want to see how it works ASAP, so I’ve used it in the simplest possible ways: sync memory search, and non-reversible injection. So, let’s cut here and there pieces of code from available examples and adapt them.
We need a memory scanner to find the code to inject into; a bit of free code memory for injection of local logic; plus a code signature to know where exactly to inject.
We can do it “the right way”, by repeating the manual process (find “VR_SNAPTURNS”, find references, find try{} block above, find flag checks and sign swap, validate we found the right and only one). But, again, we want to try it ASAP. So let’s refresh the latest binary in IDA (yes, there were a couple of game updates while I was playing with the game code), do this process manually, and get the actual signature:
// Signature:
// $do_turn$:
// (+ 0) C6 87 [1C 33 00 00] 01 mov byte ptr[rdi + 331Ch], 1; we turn
// (+ 7) F3 0F 11 [BD 58 13 00 00] movss[rbp + 1330h + arg_18], xmm7; turn radians
// $check_addr$:
// (+15) 38 9F [1C 33 00 00] cmp[rdi + 331Ch], bl
// (+21) 0F 84 [9B 23 00 00] jz no_turn_needed [ no turn pressed handling ]
// $turn_handling$:
// (+27)
const string Signature = "C6 87 1C 33 00 00 01 F3 0F 11 ?? ?? ?? 00 00 38 9F 1C 33 00 00 0F 84 ?? ?? 00 00";
Now write code to find the signature:
var thisProcess = Process.GetCurrentProcess();
byte* baseAddress = (byte*)thisProcess!.MainModule!.BaseAddress;
int exeSize = thisProcess!.MainModule!.ModuleMemorySize;
_modLoader.GetController<IScannerFactory>().TryGetTarget(out var scannerFactory);
var scanner = scannerFactory!.CreateScanner(baseAddress, exeSize);
var result = scanner.FindPattern(Signature);
if (!result.Found)
{
_logger.WriteLine("Can't find signature");
throw new Exception("Signature for getting LookHook not found.");
}
And time to think about the injection itself. We have our function that will return bool
, and we have memory holding float. So we need:
call _GetTurnAngleDiff
cmp al, 0
jz no_turn_needed
mov xmm7, [_angleDiff]
jmp do_turn_address
Keep in mind that many jumps and calls are relative; the injection point is undetermined generally, so we want to make it as position-independent as possible. So let’s make it all-globals and jump-free:
call _GetTurnAngleDiff
cmp al, 0
mov rax, qword {no_turn_needed_address}
mov rcx, qword {do_turn_address}
cmovne rax, rcx
mov rcx, qword {_angleDiffAddr}
movss xmm7, [rcx]
jmp rax
With this format all we need is code to call a function (Reloaded-II can generate it for us), and absolute addresses for the original code branches, which we can compute since we found the code above that contains them. All we need is to do right math with relative offsets:
var do_turn_address = baseAddress + result.Offset;
var turn_handling_address = baseAddress + result.Offset + 27;
var no_turn_needed_address = *(int*)(do_turn_address + 23) + do_turn_address + 27;
One more perk of Reloaded-II: we don’t need to compile our injection code ourselves, it can translate it for us, filling in required calls, addresses etc. All we need is to initialize wrappers (look into documentation or the full mod code), and put the code into an array:
string[] turnAdapterHook =
{
"use64",
// Get the rotation delta
$"{_hooks!.Utilities.GetAbsoluteCallMnemonics<NoArgsRetByte>(_GetTurnAngleDiff, out _GetTurnAngleDiffReverse)}",
// Check is no rotation needed
"cmp al, 0",
// If no rotation needed, load skip address
$"mov rax, qword {(nuint)no_turn_needed_address}",
// Otherwise load saving address
$"mov rcx, qword {(nuint)do_turn_address}",
$"cmovne rax, rcx",
// Load the turn diff into xmm7
$"mov rcx, qword {(nuint)(_angleDiff)}",
"movss xmm7, [rcx]",
// return from hook to either do_turn or no_turn_needed
"jmp rax"
};
Reloaded-II can allocate memory for its jumps, hooks, and adapters. But even then we need a place to where inject the call to hook. Existing adapters aim to inject into function headers, so they can cut initial instructions into a new place etc (which allows to make the hook disableable), but we just need some space nearby, so let’s find a gap:
using var scanner2 = scannerFactory.CreateScanner((byte*)do_turn_address, exeSize-result.Offset);
result = scanner2.FindPattern("CC CC CC CC CC CC CC CC");
if (!result.Found)
{
throw new Exception("Can't find a gap in the code.");
}
if (result.Offset > 0x7FFFF000)
{
throw new Exception("The gap is too far away.");
}
var hook_jmp_address = do_turn_address + result.Offset;
And create a simple hook there pointing to our code:
rotationHook = _hooks!.CreateAsmHook(turnAdapterHook, (long)hook_jmp_address, AsmHookBehaviour.DoNotExecuteOriginal).Activate();
The last step there is to change the address of the conditional branch from game logic to jump to the created hook trampoline:
// Activate jmp to hook by chaning "jz no_turn_needed" into "jz hook_jmp_address"
Memory.Instance.SafeWrite((nuint)(turn_handling_address - 4), BitConverter.GetBytes((int)(hook_jmp_address - turn_handling_address)));
(Full plugin code is on my github).
You won’t believe it, but the code works! I’ve tested it being at my PC and pulled the thread to turn the platform, and it looked awesome…
Right until i’ve decided to try to play on a platform. Why why why I didn’t think about it earlier?
…
…
… I guess, the reader already understood where I was dramatically wrong. While I tested it at my PC I turned ONLY the platform, but when I play on the platform the headset turns as well. As a result… Well, it turns too fast in my eyes, actually doing a double turn — definitely not what I’ve expected.
That’s how one can play a game, and lose miserably — not where they expected. %-)
Well. I’ll need to do one more try one day to learn the game’s code deeper, looking for a better place for the injection.
In the next parts
Next article we’ll look at what else one can do with the platform, what games one can play with modern tools:
- We’ll see how SDK works,
- learn about the mystery of “extraData” array,
- read the status of the platform directly,
- create our own platform data receiver for Android and test it on the headset,
- and, of course, we’ll go even deeper!
Links
- KatWalk C2 Saga, EP1: “playing actually” on [Habr], [Medium] and [LinkedIn].
- KatWalk C2 Saga, EP2: “to speak alien” on [Habr], [Medium] and [LinkedIn].
- KatWalk C2 Saga, EP3: “cutting the wire” on [Habr], [Medium] and [LinkedIn].
- KatWalk C2 Saga, EP4: “playing with firmware” on [Habr], [Medium] and [LinkedIn].
- KatWalk C2 Saga, EP5: “overclocking and bugfixing” on [Habr], [Medium] and [LinkedIn].