Introduction
For those who aren't familiar with the Star Stable titles, they are split into sections:
- Star Stable [1, 2, 3, 4] - Singleplayer games
- Star Stable Online - Modern-day Multiplayer game
Star Stable Online is built on the base of the singleplayer variants, which all take place in a different 4-season climate.
This is not a copy-paste-friendly version, and isn't aimed at that either.
While the games are abandonware and you could spin a custom server yourself, it is in a legal gray-zone.
This also doesn't cover niché features, read the bottom of the page for a small list.
ImGui setup is also not covered, but utilized a custom fork of hudhook.
For this post I'll be solely focusing on Star Stable 1, with these tools:
- Language: Rust
- Decompiler: Ghidra
- Dynamic Analysis: Cheat Engine
Game Specs
- Game Engine: PXEngine (an early 2000's version), written in C/C++
- Scripting Language: PXScript (interpreted)
- Game File Formats: Mostly all custom or packaged in custom ways. All of them are packed inside of
data.csa. - Renderer: DirectX 9
- Input Handling: ??? - Weird custom hacky method
Issue 1: The Renderer
Unlike many other DirectX 9 titles, Star Stable 1 does not utilize a proper rendering method, it instead relies heavily on hardcoded values and a screen resolution.
To combat this, you can't simply use a WinAPI call to resize the window, the viewport of the game will still remain at the initial fixed size.
The solution
It's a layered solution, working thanks to luck and heavy testing:
- Find the to-be-assigned
IDirect3DDevice9instance. This was quite easy as it's stored within a global. - Hook
IDirect3DDevice9::EndScene- On each call we drain our backlogged main-thread callbacks, more on that later.
- Hook
IDirect3DDevice9::CreateDevice- Patch the
D3DPRESENT_PARAMETERSparameter to a custom width, height and then callSetWindowPos. - Call the original
CreateDevicefunction and get the result - Once we've got the result of
CreateDevice, we hook intoIDirect3DDevice9::Reset, ensuring it only happens once viaOnce.- Once
Resetis called, we make sure to re-patch theD3DPRESENT_PARAMETERSparameter before callingSetWindowPos
- Once
- Return the cached
CreateDeviceresult from the hookedCreateDevice, allowing control-flow to continue.
- Patch the
- Hook
user32::SetWindowPos- On each call we force override the desired width and height to always match what the renderer has been forced to, which is defined via a settings file.
The result is we get a mostly fixed renderer which works with modern displays, high refresh-rate displays and so on.
The hooks are messier than they should be, and that's because modern Windows has worse DirectX 9 "support" (DX9on12) than Linux through Wine.
Issue 2: Finding game actors
Almost everything in PXEngine-based games inherit CPXActor, so we need a way to find them.
As with many games, it utilizes a tree-like layout of how actors are defined, to put it simply. There's a ton of weird "shortcut" hacks that the engine setup, but we'll ignore those.
Your players short-path is at global/Player for example, global being a shortcut of another actor.
The solution
PXEngine has a function for finding game actors via tree-path, so I just implemented it, added null-checks and return the pointer.
Issue 3: Executing game scripts
Game scripts will help with iterating and testing in-game at runtime.
The solution
Luckily this one was also easy to implement and find, which was done by attaching a debugger for when I execute a script and just reversing the right function.
Now we can do something like:
CPXActor::NULL.execute_script("global/Horse.Stop();").expect("err");
Issue 4: Abuse the scripting foundation
PXScript is implemented through a "command system" which relies heavily on inheritance and OOP, each command calls back to native code.
To differ commands against different actor classes, PXEngine splits it apart, each command is assigned an ID bound to a specific Class ID.
If you got for example, CPXSkinMesh and call Stop on it, since every actor has an OnCommand function, it just recursively goes down the tree of inherited actors of the current actor.
Stop is a command owned by CPXActor, so it roughly goes like this:
-
CPXSkinMesh::OnCommand- Lookup ID of
Stop- Not found: fallback to parent actor class'
OnCommandand repeat - Found: Execute
- Not found: fallback to parent actor class'
- Lookup ID of
It's ugly, but it works.
What we want out of it
I don't wanna go and find every single native function pointer for everything, and I also don't wanna use PXScript for everything as it's gonna hit a hard barrier eventually and it's not capable of returning native data into Rust, obviously.
So I want to be able to have something like:
// &self = CPXActor pointer
unsafe fn run_command<S: AsRef<str>>(&self, class_name: S, command: S, command_param: <Magic Param Type>) -> Result<*const <Magic Return Type>
This is a cut-down version of how it actually looks like, since it's very complex and utilize a lot of code that has shot me in the back from undefined behavior before.
The solution
I looked up the functions for when the engine starts registering a class, a command and when it ends, then hooked all 3 functions and proceeded with:
- Start Registering a Class:
- Read the Class Name and ID that's being registered, insert it into a global
HashMap - Store the current-registering Class ID globally via an atomic
- Read the Class Name and ID that's being registered, insert it into a global
- Start Registering a Command:
- Read the Command string and Command ID, storing it into the
HashMapof the current-registering Actor Class ID.
- Read the Command string and Command ID, storing it into the
- Create some helper functions to look up a command via Class Name/ID and so on
- Implement
run_commandand test it
Done, we can now call commands like:
let result = self.run_command("Actor", "Start", ()).expect("err");
It doesn't work for everything, PXEngine utilizes a lot of weird hacks, but it works for most things and we utilize PXScript (or just implement the native function) when needed.
Issue 5: Spawning other players (& horses)
This one may seem easy, and it is quite easy to do via shortcuts.
But doing it from-scratch without utilizing pre-baked objects ("Prefabs" / "Scenes") which are dubbed with the PXO extension, is a nightmare.
The solution
Luckily for me, the game has both the player and horse saved as pre-baked objects.
To spawn them though, we have to:
- Call
CreateObjectwith the right parameters, and the right Class ID (in this case:FileObject) - Assign the correct "FileObject" resource to the actor, via
SetFileObjectNameand loading viaFileObjectLoad - ... Done?
No.
Our player/horse isn't the FileObject, it's within the FileObject, so we have to move it out which can be done via the Move command.
I just chose to move it to a custom-created global actor that was always active and responsible for holding all players (& horses).
One problem showed up though: The player/horse has physics.
The fix? Just kill the physics via StopPhysics, problem solved - we don't need it anyways.
Once all spawned, we rename the spawned player/horse to whatever we want, within bounds of Aa-Zz 0-9 and basic underscores & whitespaces, alongside limiting the name to 32 characters if I can remember correctly.
Finished Example:
let player = CPXPlayer::new("Albert").expect("err");
let horse = CPXHorse::new("McHorse").expect("err");
Issue 6: Displaying player names
This one took quite a while to solve, because the PXEngine version fo the time had no in-world 3D text, so we had no nametags.
You could likely wire it yourself, but I chose not to.
I remember cheats usually using WorldToScreen for ESPs and such, so I started from there.
The solution
Well, finding it wasn't easy, but I found it eventually.
It also enabled me to create the inverse, like ScreenToWorld and utilizing raycasting, but that's a whole different topic.
To render nametags, I did this:
- Calculate the screen-space position of a player, with some offset to get above the head
- Utilize ImGui draw lists to render text over the game viewport itself
- Position the text at the right screen-space position
- Bonus: Make the text fade out if far away, and make it only visible when within screen-bounds & within the looking-direction of the player.
Example:
let ssp = CPXScene::get().expect("err").world_to_screen(player_offset_position);
Actual Code:
// Slave = Networked Player, Master = Current Client Player
ardent_ui_renderer(master_player, master_player_camera, slave_player, slave_name).expect("err");
Issue 7: Animations
This is relatively simple, there are functions on both the player and horse for playing an animation.
What wasn't so easy though was finding the current animation.
The solution
I just opted for hooking the functions for playing an animation, globally queued a packet for the server and then it sent that off whenever it was free.
Issue 8: The Server
A game server is always going to be a hassle to get right, that's correct.
But what I didn't expect to run into was beating my head into a wall over the network stacks themselves.
For reference, I started with TCP, it worked fine at the start but effectively shot itself when the server switched to 60Hz frequency (16.67ms tick-rate).
So what, switch to UDP? sure, let's try that.
UDP
UDP actually did work quite well at the start, but as everyone knows: Raw UDP is FedEx, they don't give a shit and will throw your package through your grandma's window and have you pick it up from her face if it so means saving time.
This caused problems with player spawning packets not always being received.
Yes I know about ACKs, but at the stage the server was at, it was a nightmare to even try and implement that while also trying to reverse a whole game.
Our lord and savior
QUIC, via the noq crate.
I've heard of it before, mostly from websites, but it does work for full-fledged servers too (and I'd highly recommend it after this).
The solution
- Take the old raw UDP code and throw it out the window
- Implement QUIC, it took about a day to transition both the client and server to it
- Test, and shockingly enough: It worked, most of the bugs from before were gone and the leftovers is likely me missing some edge-cases with packet handling as I never finished it.
Send and Receive (S/R) data
For sending data, I first used serde_json - unique, I know. To be extra unique, I compressed and decompressed all the data.
This worked, but since I don't like wasting bandwidth, I switched to rkyv which I've used before.
The transition took approx. 30m to 1 hour, the codebase(s) tried to follow the KISS-principle as much as possible, all code is documented for example.
Issue 9: Thread Safety
PXEngine is not thread-safe, and likely never will be (yes, it's alive and renamed to 'Stellar' in Star Stable Online).
Why was this a problem? oh well, the client implementation was perhaps asynchronous, and what do we get if we mix the two? an error would be the best case, but this was either silent UB or clueless crashes.
The solution
Remember the EndScene hook and how that's called every game frame? Yeah:
- Create a global Vec/List (the debate never ends) of callbacks to execute at the end of the next frame, type:
Box<dyn FnOnce() -> Result<()>> - Add callbacks via
ArdentMainThreadCallbacks::schedule_callback - Pop the entire Vec/List when calling
ArdentMainThreadCallbacks::execute_callbacks, if a callback returns anErrvalue, then the call-chain is interrupted and error logged tostderr; another try is made next frame with the remaining callbacks, repeat.
Actually, not really done.
Box<dyn FnOnce() -> Result<()>> is not thread-safe.
My fix?
/// Ardent hacks `Box<dyn FnOnce() -> anyhow::Result<()>>` for thread-safety.
/// This is classified safe as it's the `FnOnce` entries stored are always called
/// from within `EndScene` on the DX9 renderer, and never anywhere else.
pub struct ArdentFnOnceSyncHack(pub Box<dyn FnOnce() -> Result<()>>);
// Safety: See above.
unsafe impl Send for ArdentFnOnceSyncHack {}
unsafe impl Sync for ArdentFnOnceSyncHack {}
No more brat-like compiler errors, and yes; This actually did remove all thread-safety-linked crashes.
The End
This was all a very slimmed-down version of how it all played out, in the process of it all I had to develop a ton of systems to account for multiplayer in a singleplayer title, I also had to patch things like:
- Player/Horse Stats - otherwise it would conflict with spawned player/horse stats
- Items - clothes and such have a hardcoded string to lookup the client player when equipped, which makes every networked player apply stuff onto us rather than themselves.
- This entire system was never finished, it's so badly written and it plagues Star Stable Online to this very day, just that they have over 10k items all relying on this terrible system.
- This also required me patching the entire actor-finding function for it to even remotely work.
- Input handling - by default we'd otherwise be able to steer networked players and horses
- "Insert CD-ROM" checks
- Horse variants - otherwise we'd be out-of-sync with the players horse model and coat
- Proper horse speed - otherwise the horse animations look off if we don't do this
- Tricking the game engine into keeping select "stale resources" loaded - if not, some player items won't be synced and will lead to an error as they're freed from memory
and much more. Keep in mind, this was all done in approx. 2 months, spawning from me being bored and just diving head-first into testing my luck.
Then I also developed:
- A custom SIMD AABB collision system as I didn't wanna use their own ODE implementation
- Custom game engine editor-like UI, with object spawning/removal/save support
- Custom race system
- Mini PXScript anti-cheat with server-side validation
- DirectX 9 Runtime Texture Swaps without memory leaks
- This also offered restoration of swapped textures
- ImGui theme that nearly 1:1 cloned the games own UI theme
- A modern player and horse client loop, replacing PXEngine's own buggy loops
- This made input separation and such 20x easier too
Release
I don't think I will ever release this project public, named "Ardent" if you hadn't figured it out by now.
Maybe one day I will, but for now it'll keep collecting dust on my PC, with the usual every-now-and-then DM of "Where's Ardent?".
Why Rust?
Because I don't like C, and I absolutely hate C++.
Zig? I don't care about it.
I've been programming in basically only Rust for years now, I think I've written more unsafe Rust than safe.
That may trigger the forbid_unsafe cultists, but so be it.
If your gig is to strap a condom over your fingers because you are insecure about your own skills as a low-level programmer, then that's on you.
A tip for those who wanna learn unsafe Rust: Just start.
Reverse-engineer that childhood game of yours, dive head-first into it and assuming you're determined enough; You'll come back with 7x more experience than you'll ever get from any tutorial or book.
Top comments (0)