Hades ultrawide mod

video games

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.

Context

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:

Hades pillarboxed with side art at 3440x1440

Unfortunately, at 3840x1600 (the resolution I’m playing at), the pillars are miscalculated and displayed offset to the top:

Hades pillarboxed with side art at a glitched offset at 3840x1600

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:

Hades pillarboxed with black bars at 3840x1600

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? 🤔

Supergiant Games’ stance

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:

  • At present, it would only be used by an extremely minor subset of the target audience.
  • No support is better than bad support: the same minority would be very upset (and rightly so) if ultrawide was marketed as supported when it actually is not.

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 😇

Modding research

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! 👷

Initial analysis

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):

Hades backends on startup from 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.

.NET decompiling with dnSpy

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:

  • Game starts and initializes a static virtual viewport of 1920x1080.
    • The virtual viewport is used for all in-engine calculations.
  • It probes for the actual screen resolution – from the hardware or from user-provided settings.
  • It then maps the virtual viewport to the actual display resolution using static scaling:
    • If resolution is 16:9, it renders the viewport as-is.
    • If resolution has a narrower aspect ratio (e.g. 4:3), it letterboxes the viewport.
    • If resolution has a wider aspect ratio (e.g. 21:9), it pillarboxes the viewport.

We’ll try to patch the DLL by changing the default virtual viewport from 1920x1080 (0x780x0x438 in hexadecimal) to 3840x1600 (0xF00x0x640):

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:

Decompiling `EngineWin64.dll` using 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.

Hades engine switch

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.

Native decompiling with Ghidra

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:

  • The GUI did not scale with the viewport: it stayed locked in the top-left corner at 1920x1080.
  • The targeting system is acting strange: it seemingly calculates where to target based on the mouse position relative to the center of the 1920x1080 top-left corner (perhaps relative to the center of the GUI?). This is most visible when attacking and aiming.
  • The renderer also seems locked at 1920x1080: we can see objects ostensibly popping in and out on the edges of the screen. Unlike the other two though, it is correctly centered on the viewport and not on the top-left corner.

Analysis

Wrapping it up for the x64 (DirectX) backend:

  • The three 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.
  • Bruteforcing the virtual viewport is enough to bypass letterboxing / pillarboxing, but we still need to:
    • Fix GUI drawing to scale with and center on the viewport.
    • Fix the targeting system to correctly act relative to the center of the viewport.
    • Fix object rendering to correctly display objects in the whole viewport.

Taking a step back, it seems to me the third point is the one we should prioritize:

  • The GUI can probably be fixed. These are 2D artworks drawn on screen: there must be code somewhere that’s responsible for drawing the GUI at a fixed position, which should be bruteforceable in a similar way.
  • The targeting system can probably be fixed. Same reasoning: there must be code somewhere that’s responsible for defining the point relative to which the mouse position is taken into account for targeting calculations.
  • However, fixing object rendering is a different beast. Depending on how the engine implements its culling logic, it might either be bruteforceable as well, or be a real pain to patch if we’d need to basically reimplement the whole thing. If not worth the trouble, fixing the other two first would be a waste of time.

Hence, we’ll start to look for the code responsible for object rendering.

Double kill

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:

Decompiling `EngineWin64s.dll` using Ghidra - `Camera.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!

Third time’s the charm?

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:

Decompiling `EngineWin64s.dll` using Ghidra - `GUIComponent` methods

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:

`EngineWin64s.dll` 3840x1600 viewport+camera+GUI patch

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.

Other backends

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.

Analysis (bis)

We have demonstrated that ultrawide support can be modded in Hades, at least to some extent:

  • Hex patching the virtual viewport and camera extents allows to render the game at basically any resolution without any letterboxing or pillarboxing, on all backends.
  • Editing the Content directory allows to rearrange the GUI.

Still, we have challenges to overcome:

  • The GUI uses fixed size artwork: we have to find a way to work around annoying GUI components such as the black overlay in the videos above, either by scaling it independently, replacing it with custom-modded art, or taking it out altogether.
  • The GUI is made of numerous independent components: manually rearranging them one by one is extremely tedious, and would basically have to be redone for each resolution (3440x1440, 3840x1600…) and on each update.

A note about scaling

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?

Automated hex patching

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:

Comparing instructions to patch in `App.OnStart()` and `Camera.Camera()`

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 000xA20 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.

Automated GUI rearranging

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:

  • Sizes: Width/Height
  • Positioning: X/Y
  • Relative offsets: OffsetX/OffsetY
  • Functions that compute values derived from any of the above.

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!

Conclusion

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!

tl;dr

Hades patched with Hor+ scaling running at 3840x1600 (no pillarboxing)

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 👌


Annexes

Disabling pillarbox artwork

Either:

Hades pillarboxed with black bars at 3840x1600

Fixing pillarbox artwork at 3840x1600

As seen above, at 3840x1600 the pillars are miscalculated and displayed offset to the top:

Hades pillarboxed with side art at a glitched offset at 3840x1600

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():

Decompiling `EngineWin64s.dll` using Ghidra - `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:

  • By default (not shown on screenshot):
yOffset = 0
height = viewport.height
  • If the aspect ratio is greater than or equal to 2.4, then:
yOffset = -400
height = (texture.height / texture.width) * viewport.width
  • Then, these two values are used for creating the rectangles in which the artwork will be drawn:
left = (0, yOffset, viewport.width, height)
right = (fullViewport.width, yOffset, -viewport.width, height)

And here’s the thing:

  • At 3440x1440, the aspect ratio is 21.5:9, or roughly 2.39. All good.
  • At 3840x1600, the aspect ratio is 21.6:9, which is equal to 2.4. Woops 🙃

If we hex patch the memory value from 2.4 (0x4019999a) to 2.5 (0x40200000), the artwork will be displayed without offset at 3840x1600:

Hades pillarboxed with correct side art at 3840x1600

EDIT 2021-10-27: in V1.38246, it checks if the aspect ratio is greater than or equal to 2.41 🕵️

The mystery of the 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.) 🕵️

Previous Post Next Post