Want to play Hades in ultrawide? Use Hephaistos, the mod born out of this article 🚀
Hades is my GotY for 2020 – and it seems I’m not the only one judging by the awards on Steam 👀
No surprise here: I loved Supergiant Games’ Bastion, and am a long time action roguelike, dungeon crawler, and die and retry player (Children of Morta, Risk of Rain, Crypt of the Necrodancer, Dungeon of the Endless, Hammerwatch, Teleglitch: Die More Edition…).
Hades is just great and ticks all the right boxes for a critical hit. Go try it out, you might be surprised even if you think it’s not for you as they managed to make Hades fun and accessible – and it really is no small feat for the genre!
I could laud Supergiant Games’ Hades for quite some time, but the driving motivation behind this article is actually one single flaw: Hades does not support ultrawide resolutions.
Hades has a 16:9 aspect ratio with static scaling for non-16:9 resolutions, i.e. it can only played at 16:9 no matter the display resolution.
Supergiant Games did implement some limited support for ultrawide monitors. At 3440x1440, the game is pillarboxed with dedicated artwork rather than plain black bars:
Unfortunately, at 3840x1600 (the resolution I’m playing at), the pillars are miscalculated and displayed offset to the top:
Incidentally, I found the cause: I recommend reading on and checking out the annexes afterwards for details. EDIT 2021-10-27: bug fixed in V1.38246 of the game 🕵️
I ended up playing Hades with the side art disabled:
See annexes if you’d also like to disable the artwork.
After completing the game, while grinding heat up to 25 to unlock all bounties, the thought occurred to me: did anyone try to mod Hades and add larger viewport support? 🤔
SGG has always been transparent about the fact that its games are designed to be played at 16:9, and that allowing for larger viewports would “[introduce] a wide variety of problems” (pun intended? 😄).
This is understandable. From an independent vendor perspective, signalling customers not to buy if ultrawide support is important to them is a way more sensible and comfortable business stance than risking time and money to implement it:
As an ultrawide player, it is unfortunate that ultrawide resolutions are not supported. As a developer, I can guess the issues SGG would face when making sure its games properly support ultrawide resolutions.
As a modder, it sounds like a fun challenge 😇
Hades modding community is very active on Nexus Mods.
The SuperGiant Games’ Games Modding Group spearheads the community by providing tools to easily hook into the games’ Content
directory, which contains the assets/logic/etc. – essentially everything you need for gameplay mods.
But as expected, engine modding is relatively absent.
What about previous games then? Bastion, Transistor, and Pyre are also locked in 16:9 and do not support ultrawide resolutions, though one may hex patch Bastion and Transistor C#-based MonoGame/XNA engines to unlock them. Example for Transistor from WSGF:
It seems sensible that SGG would be reusing its in-house .NET-based engine from game to game, expanding upon its own framework. If both Bastion and Transistor could be hex patched to unlock viewport from its default 16:9 ratio, we can probably do the same for Hades.
Let’s put on our speleology helmets, it’s time to dig in! 👷
A quick inspection of Hades files reveals they are split between Content
(assets/logic/etc.) and backends, of which there are three:
Hades/
├── Content/
├── x64/
├── x64Vk/
└── x86/
These match the backends proposed on startup (here via Steam):
The structure is quite similar for each backend, with the main executable alongside several DLL and PBD files.
Here is x64
(the default DirectX backend):
x64/
...
├── Engine.dll
├── EngineWin64.dll
├── EngineWin64.pdb
├── EngineWin64d.pdb
├── EngineWin64r.pdb
├── EngineWin64s.dll
├── EngineWin64s.pdb
├── GFSDK_Aftermath_Lib.x64.dll
├── GameAnalytics.Mono.dll
├── Hades.exe
├── Hades.ilk
├── Hades.pdb
├── KeraLua.dll
├── MonoGame.Framework.Windows.dll
├── NLua.dll
├── Newtonsoft.Json.dll
├── Newtonsoft.Json.pdb
├── OpenGLRenderer.dll
├── OpenTK.dll
...
That’s neat: I did not expect to have PDBs available. To be honest though, I don’t think we’ll need them since .NET IL bytecode is very easy to decompile.
Judging only by the names, it is probable viewport calculations will be located in Engine*.dll
files: we’ll start with them.
It’s been quite some time since I last decompiled .NET code, but it seems like dnSpy is still in top shape for the job.
Let’s load Engine.dll
.
Snipping around for keywords such as viewport
and resolution
, we quickly find the code initializing the viewport to 16:9:
Code overview:
We’ll try to patch the DLL by changing the default virtual viewport from 1920x1080 (0x780
x0x438
in hexadecimal) to 3840x1600 (0xF00
x0x640
):
Now, we save the patched DLL, boot the game and… nothing changed. That would have been too easy, right? 🕵🏻♂️
That’ll give us an excuse to look at the other libraries then.
Let’s load EngineWin64.dll
in dnSpy:
Wait, that’s illegal not a .NET DLL!
I was under the assumption that SGG was still using its own C#-based fork of MonoGame/XNA, as it was with Transistor and Bastion, but this looks like a native DLL.
It turns outs Supergiant Games switched out mid-development to a brand new C/C++ engine: they rewrote it from scratch with the help of The Forge (a cross-platform rendering framework). They also talk about it in this video during the “March” section (04:15 - 06:27).
This explains why there are both .NET and native DLLs in there. This part of the code probably was ported to the new engine but not removed from the C# code afterwards, hence why editing the .NET DLL had no effect.
OK, now I’m really happy we have PDBs for native DLLs: it will definitely help with reverse engineering. Since I did not have a chance to try it out yet, I decided to go with Ghidra (I’ll spoil it now: it’s really good!).
We’ll load EngineWin64.dll
and its PBD, and start an analysis.
Thanks to the PDB, we can quickly confirm that the structure is quite similar to the .NET DLL, lending credit to our hypothesis that the code was ported.
Here we see that Resolution.Init()
got merged into App.App()
, and that Resolution.Resize()
now holds the viewport mapping / static scaling logic:
Once again, let’s try to patch the DLL by changing the default viewport from 1920x1080 to 3840x1600:
We save the patched DLL, boot the game and… nothing changed. Right. Now I feel stupid for not checking which DLLs are actually loaded by Hades on the first failure.
Let’s load Process Explorer from Sysinternals, and quickly check that indeed, the only Engine*.dll
loaded by Hades.exe
is EngineWin64s.dll
.
Well, I guess we now have a really good excuse to look at it.
Immediately, a feeling of déjà-vu: very similar code – though some of it is a bit different again.
For example, the App()
method is now called OnStart()
, and the viewport mapping / static scaling logic is back to a separate CalculateViewportForAspectRatio()
method which is called from Resize()
.
I actually wonder if this really is different code, or just the compiler inlining stuff from a shared codebase in different ways.
Yet again, let’s try to patch the DLL by changing the default viewport from 1920x1080 to 3840x1600, boot the game and…
Progress 🎉 As we can see, the viewport does respect the 3840x1600 resolution it’s been forced to. There are several immediate issues, though:
Wrapping it up for the x64
(DirectX) backend:
Engine*.dll
all roughly have the same code / structure: a virtual viewport is statically initialized when the game boots, and rendering is bound to the viewport.Engine.dll
(.NET) and EngineWin64.dll
(native) do not seem to be used at all: EngineWin64s.dll
(native) is the only one loaded.Taking a step back, it seems to me the third point is the one we should prioritize:
Hence, we’ll start to look for the code responsible for object rendering.
Since the rendering logic is a central piece, we should be able to find it by systematically examining all top-level objects created in App.OnStart()
, if necessary by recursively diving into them.
We end up finding that Camera
is the class responsible for the extents of the renderer and determining which objects should be popped in or out.
Even better: it appears Camera
is also responsible for the targeting system!
Maybe we can fix both the rendering and the targeting in one sweep.
Interestingly enough, another 1920x1080 virtual viewport is hardcoded in Camera.Camera()
and subsequently used for extents calculations.
Apparently, the App
/Resolution
virtual viewport found earlier is never passed down to Camera
:
This might be due to the compiler inlining constants.
Let’s check if updating the camera extents has any effect by patching the DLL again from 1920x1080 to 3840x1600 in Camera
, then boot the game and…
Nice 🥳 No more popping in or out, nor weird things happening when attacking or aiming. Patching the camera extents fixes both the rendering and the targeting system.
Two birds with one stone – two down, one to go!
With only the GUI left, we’ll search for the code responsible for drawing fixed 2D objects on screen.
Again, looking at the objects and methods, we identify multiple systems interacting together to draw the GUI, notably GUIComponent
which has all sorts of methods useful for a GUI:
It resembles an abstract class from which GUIComponent*
subclasses (GUIComponentButton
, GUIComponentTextBox
, …) inherit and reimplement / override the base methods.
Poking around, we eventually stumble on an interesting tidbit: a ParseLua
method. Eurêka! 💡
The code is built around a hierarchy of generic components that are instantiated by and take their properties from an external source – here, Lua code from the Content
directory – and this is exactly what the folks creating gameplay mods (including GUI) are editing.
Also, thinking it over again, patching the engine to change how the GUI is drawn is not a very good idea. Assuming the drawing logic expects the external source to use fixed positioning relative to the default 1920x1080 virtual viewport, if we force it to scale to larger viewports, then the artwork will get distorted. It would be better to move around components so that they get repositioned without being distorted.
Let’s try it! There’s one issue though… The Content
directory is huge:
$ find Content -type f | wc -l
4619
We’re basically looking for a needle in a haystack…
To confirm we are on the right track, we are only going to edit a few things.
First, we replace screen sizes of 1920
by 3840
in Scripts\UIData.lua
:
ScreenCenterX = 3840/2
ScreenCenterY = 1080/2
ScreenWidth = 3840
ScreenHeight = 1080
Secondly, we replace - 50
with + 150
in all health bar anchors of the ShowHealthUI
function in Scripts\UIScripts.lua
:
function ShowHealthUI()
[...]
ScreenAnchors.HealthBack = CreateScreenObstacle({Name = "BlankObstacle", Group = "Combat_UI", X = 10 - CombatUI.FadeDistance.Health, Y = ScreenHeight + 150})
ScreenAnchors.HealthRally = CreateScreenObstacle({Name = "BlankObstacle", Group = "Combat_UI", X = 10 - CombatUI.FadeDistance.Health, Y = ScreenHeight + 150})
ScreenAnchors.HealthFill = CreateScreenObstacle({Name = "BlankObstacle", Group = "Combat_UI", X = 10 - CombatUI.FadeDistance.Health, Y = ScreenHeight + 150})
ScreenAnchors.HealthFlash = CreateScreenObstacle({Name = "BlankObstacle", Group = "Combat_UI", X = 10 - CombatUI.FadeDistance.Health, Y = ScreenHeight + 150})
[...]
end
Let’s see if that works:
Champagne! 🥂
We notice that the health bar did move down, and that bottom-right resources have also moved.
Thus, the GUI can be modded directly from the Content
directory – no hex patching involved.
I’ll spare you the details: as expected, both Vulkan and 32-bit DirectX also appear to be compiled from the same source code, and can be patched identically.
The only difference is the DLL which is responsible for the engine:
Backend | Engine DLL |
---|---|
(64-bit) DirectX | x64/EngineWin64s.dll |
(64-bit) Vulkan | x64Vk/EngineWin64sv.dll |
32-bit (DirectX) | x86/EngineWin32s.dll |
I still have no idea about the purpose of the other Engine*.dll
, though. See annexes for more details.
We have demonstrated that ultrawide support can be modded in Hades, at least to some extent:
Content
directory allows to rearrange the GUI.Still, we have challenges to overcome:
By default, Hades uses static scaling to render the game at resolutions other than 1920x1080, which do not natively fit its internal virtual viewport.
As seen above, we can patch in basically any resolution: we increased virtual viewport both in width and height to match the actual screen resolution. This effectively is pixel-based scaling, resulting is a larger horizontal and vertical field of view, and displaying more of the game at once.
Problem is: the GUI was not meant to work with anything but a 1920x1080 viewport. Having to rearrange the GUI in both width and height is way more difficult than if we only had extended the game on both sides: if not touching height, we would only need to rearrange the GUI in width. This is called Hor+ scaling.
By keeping the same virtual height, we can compute the virtual width we should patch in to implement Hor+ scaling for various resolutions: \(\frac{Width}{Height} * VirtualHeight = VirtualWidth\)
Width | Height | Ratio | Virtual width |
---|---|---|---|
1920 | 1080 | 16:9 | 1920 |
2560 | 1080 | ~21.3:9 | 2560 |
3440 | 1440 | 21.5:9 | 2580 |
3840 | 1600 | 21.6:9 | 2592 |
5120 | 2160 | ~21.3:9 | 2560 |
3840 | 1080 | 32:9 | 3840 |
5120 | 1440 | 32:9 | 3840 |
Note: even with Hor+, there will be choices to make w.r.t. to GUI positioning. Some elements will have to be kept fixed on the sides, others kept in the center. In any case, Hor+ makes rearranging more manageable.
Annoyingly, due to small variations in aspect ratios for 21:9 resolutions, there are a bunch of resolutions that are close in terms of virtual width, but not quite the same. Again, manual patching would be tedious, especially since it’d need to be redone each time the game is updated. Can we automate it?
Two places need a patch: the virtual viewport in App.OnStart()
and the camera extents in Camera.Camera()
.
We could try to compute patching offsets thanks to the PDB files giving us function offsets, but it would be simpler to just “recognize” the instructions and patch them wherever they are.
The two are MOV
instructions copying 4-byte little endian static values – 1920 (0x780
) for width, 1080 (0x438
) for height:
Except for the first operand, the two places to patch are identical:
c7 05 ?? ?? ?? ?? 80 07 00 00 # both MOV ?? 1920
c7 05 ?? ?? ?? ?? 38 04 00 00 # both MOV ?? 1080
There are probably little to no other MOV
instructions with these exact same operands in the DLLs.
A memory search in Ghidra for exactly the instructions above (where ??
acts as a wildcard) confirms these are the only two instances of each.
Hence, we should be fine with a simple regex-based patch:
#!/usr/bin/env python3
from pathlib import Path
import re
regex = re.compile(b'(\xc7\x05.{4})\x80\x07\x00\x00')
data = Path('x64/EngineWin64s.dll').read_bytes()
patched = regex.sub(b'\g<1>\x20\x0a\x00\x00', data)
Path('x64/EngineWin64s.dll').write_bytes(patched)
This Python script automatically replaces both instances of c7 05 ?? ?? ?? ?? 80 07 00 00
with c7 05 ?? ?? ?? ?? 20 0a 00 00
– 0xA20
being the hexadecimal for 2592, which corresponds to the virtual width needed for Hor+ scaling at 3840x1600.
Let’s try it:
It is barebones and would have to be extended to accomodate for parameterizing but we now have proved that we can automate the hex patching.
Automating the GUI modding part is a bit more involved. For starters, as we did above, we have to snoop around the *.lua
and *.sjson
files in Content
to identify and elect what to edit.
Some examples:
Width
/Height
X
/Y
OffsetX
/OffsetY
Once we’ll have listed all the elements to be modified, we’ll be able to add them to our automated script.
I did not get around to doing this systematic categorization yet, though I intend to try my hand at it when I have more free time.
EDIT 2021-08-12: it is done!
In the end, the hardest part is not the rendering engine patch as was expected initially, but the fine-grained GUI tweaking.
Coming back to Supergiant Games messages, it is now obvious what they meant: the game – or more specifically, the GUI – is indeed designed for a 16:9 aspect ratio – or more specifically, the 1920x1080 virtual viewport.
I intend to continue working on expanding the automated mod, dubbed Hephaistos and published on GitHub.
I think it should be achievable to implement “good enough” ultrawide support, even if not perfect.
At least, it seems like a realistic goal considering the proof-of-concept above.
EDIT 2021-08-12: it is done!
By default, Hades can only played at 16:9: a 1920x1080 virtual viewport is used for all in-engine calculations.
The game engine can be modded to use a different viewport / field of view and bypass pillarboxing or letterboxing, via an hexadecimal patch depending on target resolution.
The GUI is hand-crafted and calibrated based on the internal 1920x1080 viewport.
It can be modded by editing the Content
directory but it requires more work – partial implementation above.
Head over to Hephaistos if you’d like try it out! EDIT 2021-08-12: Hephaistos now supports patching the GUI as well! We have complete ultrawide support 👌
Either:
/UseSideBarArt=false
to the game’s command line parameters from Steam/Epic.UseSideBarArt = false
to your ProfileX.sjson
configuration file.As seen above, at 3840x1600 the pillars are miscalculated and displayed offset to the top:
EDIT 2021-10-27: bug fixed in V1.38246 of the game 🕵️
It was not the initial goal, but since I was reverse engineering the static scaling logic anyway, I found the bug in ScreenManager.Draw()
:
Note: for clarity, several variables have been manually renamed.
This part of the method is responsible for drawing the pillarbox artwork when necessary. In pseudo-code, it goes something like this:
yOffset = 0
height = viewport.height
yOffset = -400
height = (texture.height / texture.width) * viewport.width
left = (0, yOffset, viewport.width, height)
right = (fullViewport.width, yOffset, -viewport.width, height)
And here’s the thing:
If we hex patch the memory value from 2.4 (0x4019999a
) to 2.5 (0x40200000
), the artwork will be displayed without offset at 3840x1600:
EDIT 2021-10-27: in V1.38246, it checks if the aspect ratio is greater than or equal to 2.41 🕵️
Engine*.dll
There are multiple Engine*.dll
files in the various backends but only one is actually loaded and used by the game (marked with *
):
Hades/
├── x64/
│ ├── Engine.dll
│ ├── EngineWin64.dll
│ └── EngineWin64s.dll*
├── x64Vk/
│ ├── EngineWin64s.dll
│ └── EngineWin64sv.dll*
└── x86/
└── EngineWin32s.dll*
Their purpose is a mystery to me.
I suspect the Engine.dll
.NET DLL to be a leftover from the engine switch and not used anymore.
This hypothesis is seemingly confirmed by the fact it does not exist for the Vulkan and 32-bit backends: it could be that the .NET version was only ever built for 64-bit.
Then, both 64-bit DirectX and 64-bit Vulkan backends have two native EngineWin64*.dll
with extremely similar code, but one of them is unused. The v
obviously stands for Vulkan, and there seems to be no difference between s
and sv
versions apart from that. But what does the s
stand for then? Steam? I’m not convinced, because both the s
and non-s
versions reference SteamWrapper.dll
(Steam API wrapper) in imports. The only difference I could spot is that the non-s
version does not have the following imports, all of which are present in s
and sv
versions:
discord_game_sdk.dll (Discord SDK)
EOSSDK-Win64-Shipping.dll (Epic Online Services SDK)
xinput9_1_0.dll (controller API)
x86
is the only non-ambiguous one, because it is the only one with a single EngineWin32s.dll
(which has the same imports as the other EngineWin64s*.dll
).
EDIT 2022-07-24: Seems like I was correct and none of these were used, because all were removed in a silent update (no patch notes), along with other files which probably were also used only on the .NET version (MonoGame.Framework.Windows.dll
, NLua.dll
, etc.) 🕵️