This repository has been archived, in favor of the Cobra by The Peacock Project.
This project emulates the server used by Hitman: Absolution and the Hitman: Sniper Challenge and aims to restore all its features, including the Contracts feature.
In order to get started with the code, you need to have one of the following IDEs/editors installed:
- Visual Studio 2022 (recommended)
- Jetbrains Rider
- Visual Studio Code with vscode-solution-explorer (recommended) and C# (required) installed.
On top of that, you need to have the .NET SDK for your OS installed (not the Runtime). The current project is based on .NET 6 LTS. You can however install any newer version you like.
The hook supports both Hitman Absolution and Hitman Sniper Challenge depending on what you set in the hook.ini
. Both allow to change the WebService URL. Hitman Absolution additionally allows you to skip the launcher.
The dinput8.dll hook is based on C++. It's beyond the scope of this document to explain how to compile that.
However, you can grab a precompiled copy from the Bin\Hook
folder on this repository. Just drop it in the game folder along with the default hook.ini
file, apply the desired changes and start the game.
The hook does not crack the games in any sort of way, you still need a legitimate copy in order to make use of this project.
In its current state, almost everything is reverse engineered and working with mocked data. But there are still a few things to figure out to restore the full experience.
Phase 1:
Figure out if game can actually POST a bodyNope! It can't.Figure out if GetFeaturedContract is actually usedYes! After loading a level in Singleplayer. Will be shown on the in-game menu.Figure out if GetScoreComparison is actually usedYes! After loading a level in Singleplayer. Will be shown on the in-game HUD at the start of a level.Figure out if __metadata is even needed anywhereNope! Doesn't seem like it at least.- Figure out if SendTemplatedMessage is actually used.
- Add Unit Tests based on real requests made by the game.
Phase 2:
- Add EntityFramework and define a database schema
- Replace all mocked with actual functionality
Phase 3:
- Expose leaderboards through Web UI
- Figure out how to patch Hitman Sniper Challenge without triggering anti-debugging measures to skip launcher.
- IDEA: Hook Steam Friends API to hook up own account system with friends.
- UserId is almost always a 64-bit SteamId. However, to stay on the safe side all UserId-properties are treated as a String.
- ContractId is considered a String in-game, since
Play_01
is used by the Contracts tutorial. This also has an effect on the LeaderboardId-properties, since that property can be a ContractId if the game wants to receive Contract-specific leaderboards.- IDEA: Use the hook to change these ID's to a negative number so the data model can keep using Integers.
Message are based on pre-determined localized texts that shipped with the game. In order to make a distinction between the title and the body of a message, these texts contain a |||
as a separator, for example: Title|||Body
.
The game allows the substitute parts of these texts by providing TemplateData
. The game considers this property to be a key-value dictionary, but only if TextTemplateId
is an actual valid Id.
A valid TemplateData
that has two key-value pairs would be Key1|||Value1|||Key2|||Value2
.
The game treats a few key-value pairs in a special way:
{ContractId}|||1
will enable the "Go To Contract"-button in-game and when clicked, opens up the contract with Id 1. This button won't be visible if this key-value pair is missing.{userid}
,{CompetitionCreatorName}
and{WinnerName}
are used as the sender of the message if theFromId
-property is missing.
All key-value pairs, even the ones above, can be used to substitute the key with the value in the eventual to-be-displayed text.
If you set the TextTemplateId
to 0, the TemplateData
will be considered the to-be-displayed text itself and does not allow any form of substitution. This causes a problem, as you will not be able to associate a contract with a message.
Luckily, we can abuse the substitution system. All localized texts appear to have an entry for the word Silverballer
. So by setting the TextTemplateId
to that, we can effectively replace Silverballer
with anything we want and provide a {ContractId}
on top of that.
When the game converts TemplateData
to a dictionary, it will search for the first |||
it can find, then substring the value before it, skip past the |||
and repeat. It expects to have an even number of substrings at the end, otherwise the game will crash.
This is problematic for the issue we want to solve, because if we try to replace Silverballer
like this: Silverballer|||<Title>|||<Body>
, it will cause an un-even number of substrings. So we have to do two substitutions the get the |||
to be part of the final text, like this: Silverballer|||<Title>||{Body}|||{Body}||||<Body>
.
Unfortunately, the TemplateData
dictionary uses hashes to sort the key-value pairs, so on top of doing two substitutions, we also need to have the game do them in the right order. After some playing, this is the end result: Silverballer|||<Title>||{baller}|||{baller}||||<Body>
. The only limitation now is that the contents of <Title>
can't contain {baller}
, but that seems acceptable.
This functionality is bundled in a suitably named MasterCraftedSilverballer
method.
Even though we can create any message ourselves now, it's interesting to go through all the messages that could be used by the game.
Out of the hundreds of available messages, only a few were actually used by the game. Most of these can be recognized by the ALL CAPS titles.
The other ones tell an interesting story, as the message system was apparently supposed to have a much bigger role than it ended up getting. A few interesting concepts:
- Adding wagers to competitions, where the winner takes it all.
- Hitman players could join an agency, agencies would then have their own leaderboards.
- Agencies could be put up against each other in a Face-Off, what appears to be a global competition.
This section aims to explain the inner workings of the game in relation to the API endpoints that need to be reverse engineered.
The first interesting calls happen here:
ZOnlineManagerWindows::Update
ZOnlineManager::Update
- Called near a
_SteamAPI_RunCallbacks
-call - Contains a lot of timed events that call API endpoints regularly
- Called near a
If the webservice is not connected yet, it will call ZOSWebService::Connect
followed by OSuite::ZOnlineSuite::CreateWebServiceClient
. In this function the configured service-url is passed to OSuite::ZWebServiceClientManager::Create
.
Since there is no instance for this webservice yet, it will go into OSuite::ZWebServiceClient::Initialize
. This function registers the os_getStatus
and os_$metadata
endpoints with a callback to OSuite::ZWebServiceClient::InternalProbeResultCallback
. Eventually, OSuite::ZWebServiceClient::ProbeAvailability
is called.
OSuite::ZWebServiceClient::ProbeAvailability
will prepare the request for the os_getStatus
endpoint and use OSuite::ZWebServiceClient::InternalProbeCallback
as a callback. This callback is responsible for parsing the response.
If successfully parsed, OSuite::ZWebServiceClient::InternalProbeResultCallback
is called which will prepare the request to the os_$metadata
endpoint with OSuite::ZWebServiceClient::InternalMetadataCallback
as a callback.
Unlike the os_getStatus
request, a cache is used for the metadata and OSuite::ZAtomCache::Open<OSuite::ZOMetadata>
will be called to fetch the result from the endpoint. The response is parsed (see the notes about OSuite::ZAtomCache::Open
below) and the OSuite::ZWebServiceClient::InternalMetadataCallback
callback is called.
This function will check if a valid Metadata-response was given through OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZOMetadata>
and if there was, flag the ZWebServiceClient as connected (m_eStatus = 2 //READY_STATE
)
Something very important happens in any OSuite::ZAtomCache::Open<T>
-function, as it will make an instance of a TAtomObject<T>
. When the response comes back from the endpoint, this instance will get its Read
-function called, which will then make an instance of the generic type.
There are a few of these Read
-functions responsible for creating instances for:
OSuite::ZOEntry
OSuite::ZOFeed
OSuite::ZOMetadata
OSuite::ZOServiceOperationResult
Any of these constructors will call their respective ParseJsonValue
-function.
ZOSServiceOperation::Invoke
with callback- Callback can be used to find the expected response-type based on usage of the following functions:
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZOFeed>
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZOEntry>
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZServiceOperationResult>
- See ReturnType below to find out what to set ReturnType to.
- Callback can be used to find the expected response-type based on usage of the following functions:
ZOSQueryManager::Push
=>ZOSServiceOperation::Execute
=>OSuite::ZWebServiceClient::ExecuteQuery
OSuite::ZWebServiceClient::ExecuteQuery
will the try to get API endpoint. It bases this on the QueryMode, which can either be:
- QM_NONE //0
- QM_ENTITYSET //1
- QM_FUNCTIONIMPORT //2
This will always generate an internal 404, which causes the webservice to disconnect and show the Disconnected-dialog in-game (this happens with the ShowDialog
in ZOnlineManager::Update
).
OSuite::ZOMetadata::FunctionImport
will be called and if this fails an internal 404 is generated.
It will then check if the FunctionImport has all the querystring-parameters specified. If something is missing, again an internal 404 is generated.
Otherwise, it will continue and call the endpoint based on the data from the FunctionImport. It can make either a GET or a POST request (HttpMethod
). Based on the ReturnType
of the FunctionImport it will decide to either expect a ZOEntry
, ZOServiceOperationResult
or a ZOFeed
.
OSuite::ZOMetadata::EntitySet
will be called and always result in a GET-request for a ZOFeed
.
These are the different ReturnType:
- SVOP_FEED = 0x0 =>
OSuite::ZOFeed
- SVOP_ENTRY = 0x1 =>
OSuite::ZOEntry
- SVOP_VALUE = 0x2 =>
OSuite::ZOServiceOperationResult
- SVOP_VALUECOLLECTION = 0x3
- SVOP_VOID = 0x4
The following pseudocode shows how the game will convert the value of the ReturnType
on a EdmFunctionImport
to a ReturnType enumeration value:
this->entityName = ReturnType;
this->returnType = SVOP_VOID;
var isEntityType = !this->entityName.Contains("Edm.")
if(this->entityName->StartsWith("Collection"))
{
if(isEntityType) {
this->returnType = SVOP_FEED
}
else {
this->returnType = SVOP_VALUECOLLECTION
}
this->entityName = "Collection(this->entityName)"
}
else if(isEntityType) {
this->returnType = SVOP_ENTRY
}
else {
this->returnType = SVOP_VALUE
}
JSONTYPE_STRING = 0x0, JSONTYPE_OBJECT = 0x1, JSONTYPE_ARRAY = 0x2,
When OSuite::ZAtomBase::ParseJson
is called:
- It will loop over all key-value pairs of the JSON-object that is passed in as the second argument.
- Each key-value pair will be passed to a
ParseJsonValue
-function, which is determined by the type ofZAtomBase
-object passed in as the first argument.
There are a few of the ZAtomBase
-objects in the game:
ZServiceOperationValue
ZServiceOperationResult
ZAtomFeed
ZAtomEntry
ZOMetadata
The ZOMetadata
-object is responsible for parsing the metadata-response from the API. It will call the OSuite::ZOMetadata::ParseSchema
, which will call the ParseJson
-function, which will call the ParseJsonValue
-function of passed-in ZEdmBase
-object.
There are a few of the ZEdmBase
-objects in the game:
ZOEdmEntityType
ZOEdmComplexType
ZOEdmAssociation
ZOEdmFunctionImport
ZOEdmClientConfiguration
- NOTE: Instanced from
ZOMetadata->ParseJsonValue
- NOTE: Instanced from
- ZOEntry is always an EdmEntityType
- Can contain a "results" which then contains the ZOEntry, but this is not required.
- ZOFeed appears to support object and literal value. Not sure about array.
- object should contain a "results" and "__count", results is then treated as an array of ZOEntry.
- ZOServiceOperationResult can be a single value or an array of values
- All values will be converted to a ZServiceOperationValue
- In-memory a ZOServiceOperationResult is always a list of 1 or more ZServiceOperationValue
- ZServiceOperationValue can be a single value or an object
These are just some random notes.
OSuite::ZOEntry::ParseJsonValue
,OSuite::ZOFeed::ParseJsonValue
andOSuite::ZOServiceOperationResult::ParseJsonValue
describe how to parse their respective type.- The use of
OSuite::ZOEntry::Property
describes an expected property on aZOEntry
. - The second argument of
OSuite::ZOQuery::EntitySet
is the name of the expected EntitySet, - The first argument of
ZOSServiceOperation::Invoke
is the name of the expectedEdmImportFunction