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.
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:
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:
Phase 2:
Phase 3:
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.
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 the FromId
-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:
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
_SteamAPI_RunCallbacks
-callIf 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
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZOFeed>
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZOEntry>
OSuite::ZWebServiceClient::RetrieveRequest<OSuite::ZServiceOperationResult>
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:
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:
OSuite::ZOFeed
OSuite::ZOEntry
OSuite::ZOServiceOperationResult
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:
ParseJsonValue
-function, which is determined by the type of ZAtomBase
-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
ZOMetadata->ParseJsonValue
These are just some random notes.
OSuite::ZOEntry::ParseJsonValue
, OSuite::ZOFeed::ParseJsonValue
and OSuite::ZOServiceOperationResult::ParseJsonValue
describe how to parse their respective type.OSuite::ZOEntry::Property
describes an expected property on a ZOEntry
.OSuite::ZOQuery::EntitySet
is the name of the expected EntitySet,ZOSServiceOperation::Invoke
is the name of the expected EdmImportFunction