Envie de jouer à Hades en résolution ultra-large ? Utilisez Hephaistos, le mod né de cet article 🚀
Hades est mon GotY pour 2020 – et il semble que je ne sois pas seul d’après les récompenses sur Steam 👀
Prévisible : j’ai adoré Bastion de Supergiant Games, et suis un grand fan d’action roguelike, dungeon crawler et die and retry (Children of Morta, Risk of Rain, Crypt of the Necrodancer, Dungeon of the Endless, Hammerwatch, Teleglitch: Die More Edition…)
Hades est juste génial et coche toutes les cases de la réussite critique. Essayez-le, vous pourriez être surpris même si ce n’est pas votre style de jeu étant donné qu’ils ont réussi à rendre Hades à la fois amusant et accessible – et c’est vraiment pas une mince affaire pour le genre !
Je pourrais faire l’éloge d’Hades et Supergiant Games pendant un certain temps, mais si j’écris cet article c’est à cause d’un unique défaut : Hades n’est pas compatible avec les résolutions ultra-larges.
Hades a un format d’image 16:9 fixe avec mise à l’échelle statique pour les résolutions non 16:9, i.e. on ne peut y jouer qu’en 16:9 peu importe la résolution d’affichage.
Supergiant Games a tout de même eu une pensée pour les joueurs sur écrans ultra-larges. En 3440x1440, le jeu est encadré latéralement (pillarboxing) par des illustrations dédiées plutôt que de simples bandes noires :
Malheureusement, en 3840x1600 (la résolution à laquelle je joue), les bandes latérales sont mal calculées et affichées décalées vers le haut :
Au passage, j’ai trouvé la cause : je recommande de lire la suite puis de jeter un œil aux annexes pour les détails. ÉDITH 2021-10-27: bug corrigé dans la version V1.38246 du jeu 🕵️
J’ai fini par jouer à Hades avec les illustrations latérales désactivées :
Voir les annexes si vous souhaitez également désactiver les illustrations latérales.
Après avoir terminé le jeu, je grindais jusqu’à 25 de chaleur pour débloquer toutes les primes, quand je me suis demandé : peut-on modder Hades pour permettre un viewport élargi ? 🤔
SGG a toujours été transparent à propos du fait que ses jeux sont conçus pour être joués en 16:9, et qu’autoriser des formats élargis pourrait « introduire une large panoplie de problèmes » (jeu de mots intentionnel ? 😄).
C’est compréhensible. Pour un développeur indépendant, signaler aux clients de ne pas acheter si la compatibilité ultra-large est importante pour eux est une position commerciale bien plus sensée et confortable que de risquer du temps et de l’argent pour l’implémenter :
En tant que joueur, il est regrettable que les résolutions ultra-larges ne soient pas prises en charge. En tant que développeur, je peux deviner les problèmes auxquels SGG serait confronté afin de s’assurer que ses jeux gèrent correctement les résolutions ultra-larges.
En tant que moddeur, ça ressemble à un super défi 😇
La communauté de modding Hades est très active sur Nexus Mods.
Le SuperGiant Games’ Games Modding Group est le fer de lance de la communauté, fournissant des outils pour s’interfacer facilement avec le répertoire Content
du jeu, qui contient les images/logiques de jeu/etc. – tout ce dont on a besoin pour les mods de gameplay.
Mais comme prévu, le modding du moteur est relativement absent.
Qu’en est-il des jeux précédents ? Bastion, Transistor et Pyre sont également bloqués en 16:9 et ne sont pas compatibles avec les résolutions ultra-larges, mais on peut appliquer un patch hexadécimal sur les moteurs MonoGame/XNA (C#) de Bastion et Transistor pour les déverouiller. Exemple avec Transistor par WSGF :
On peut supposer que SGG réutilise son moteur interne basé sur .NET d’un jeu à l’autre, l’enrichissant au fur et à mesure. Si Bastion et Transistor ont pu être patchés en hexadécimal pour déverrouiller leur viewport 16:9 par défaut, on peut probablement faire de même pour Hades.
À vos casques, c’est l’heure de l’expédition spéléologique ! 👷
Une inspection rapide des fichiers d’Hades révèle qu’ils sont répartis entre Content
(images/logiques de jeu/etc.) et backends, au nombre de trois :
Hades/
├── Content/
├── x64/
├── x64Vk/
└── x86/
Cela correspond aux backends proposés au lancement (ici via Steam) :
La structure est assez similaire pour chaque backend, avec l’exécutable principal et plusieurs fichiers DLL et PBD.
Ici, x64
(le backend par défaut, sur DirectX) :
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
...
Sympa : je ne m’attendais pas à avoir des PDB disponibles. Même si pour être honnête, je ne pense pas qu’on en aura besoin car le bytecode IL .NET est très facile à décompiler.
En se basant uniquement sur les noms, il est probable que les calculs relatifs au viewport soient situés dans les fichiers Engine*.dll
: commençons par là.
Je n’ai pas décompilé de code .NET depuis un certain temps, mais on dirait que dnSpy fait toujours parfaitement bien le travail.
Chargons Engine.dll
.
En recherchant des mots-clés tels que viewport
et resolution
, on identifie rapidement le code initialisant le viewport au format 16:9 :
Vue d’ensemble :
Essayons de patcher la DLL en changeant le viewport virtuel par défaut depuis 1920x1080 (0x780
x0x438
en hexadécimal) vers 3840x1600 (0xF00
x0x640
) :
Maintenant, sauvegardons la DLL patchée, démarrons le jeu et… rien n’a changé. C’eût été trop facile, non ? 🕵🏻♂️
On a une excuse pour examiner les autres DLLs au moins.
Chargeons EngineWin64.dll
dans dnSpy :
Attends voir… ceci n’est pas une pipe DLL .NET !
Je pensais que SGG utilisait toujours son propre moteur MonoGame/XNA basé sur C#, comme c’était le cas pour Transistor et Bastion, mais ça ressemble à une DLL native.
Après vérification, il s’avère que Supergiant Games a migré mi-développement vers un tout nouveau moteur C/C++, réécrit de zéro avec l’aide de The Forge (un cadriciel de rendu multiplateforme). Ils en parlent également dans cette vidéo pendant la section « March » (04:15 - 06:27).
Ça explique pourquoi il y a à la fois des DLLs .NET et des DLLs natives. Probable que cette partie du code ait été portée vers le nouveau moteur mais n’ait pas été supprimée du code C# par la suite, d’où la raison pour laquelle modifier la DLL .NET n’a aucun effet.
OK, au final je suis super content d’avoir des PDBs pour les DLLs natives : ça va grandement aider pour la rétro-ingénierie. Comme je n’avais pas encore eu l’occasion de l’essayer, j’ai décidé d’utiliser Ghidra (je vous le divulgache de suite : c’est vraiment bien !).
Chargeons EngineWin64.dll
et son PBD, et lançons une analyse.
Grâce au PDB, on peut rapidement confirmer que la structure est assez similaire à la DLL .NET, ce qui appuie notre hypothèse de portage de code.
Ici, on voit que Resolution.Init()
a été fusionné avec App.App()
, et que Resolution.Resize()
contient maintenant la logique de transformation du viewport / mise à l’échelle statique :
À nouveau, essayons de patcher la DLL en changeant le viewport virtuel par défaut depuis 1920x1080 vers 3840x1600 :
On sauvegarde la DLL patchée, démarre le jeu et… rien n’a changé. Flûte. Je me sens bête de ne pas avoir vérifié quelles DLLs sont réellement chargées par Hades dès le premier échec.
Lançons Process Explorer de Sysinternals, et vérifions rapidement qu’effectivement, la seule Engine*.dll
chargée par Hades.exe
est EngineWin64s.dll
.
Bon, eh bien maintenant on a vraiment une super excuse pour aller l’inspecter.
Immédiatement, une sensation de déjà-vu : code très similaire – bien que certains morceaux soient encore une fois un peu différents.
Par exemple, la méthode App()
est maintenant appelée OnStart()
, et la logique de transformation du viewport / mise à l’échelle statique est à nouveau située dans une méthode CalculateViewportForAspectRatio()
distincte, appelée depuis Resize()
.
Je me demande à quel point il s’agit vraiment d’un code différent, ou simplement du compilateur inlinant de différentes manières une base de code partagée.
Encore une fois, essayons de patcher la DLL en changeant le viewport virtuel par défaut depuis 1920x1080 vers 3840x1600, démarrons le jeu et…
Ça progresse 🎉 Comme on peut le constater, le viewport virtuel respecte la résolution de 3840x1600 à laquelle il a été forcé. Plusieurs problèmes apparents :
En résume, pour le backend x64
(DirectX) :
Engine*.dll
ont toutes à peu près les mêmes code / structure : un viewport virtuel est initialisé de manière statique lorsque le jeu démarre, et le rendu d’affichage est dépendant du viewport.Engine.dll
(.NET) et EngineWin64.dll
(native) ne semblent pas être utilisées du tout : EngineWin64s.dll
(native) est la seule chargée.En prenant un peu de recul, il me semble logique d’aborder en priorité le troisième point :
Par conséquent, nous allons commencer par chercher le code responsable du rendu des objets.
Puisque la logique de rendu est centrale, on devrait pouvoir la trouver en examinant systématiquement tous les objets de premier niveau créés dans App.OnStart()
, si nécessaire en y plongeant récursivement.
On finit par découvrir que Camera
est la classe responsable de l’étendue du rendu affiché à l’utilisateur et de déterminer quels objets doivent apparaître et disparaître.
Encore mieux : il semble que Camera
soit également responsable du système de ciblage !
Peut-être va-t-on corriger le rendu et le ciblage d’un seul coup.
On note qu’un autre viewport virtuel de 1920x1080 est codé en dur dans Camera.Camera()
puis utilisé pour les calculs de l’étendue du rendu.
Apparemment, le viewport virtuel de App
/Resolution
trouvé précédemment n’est jamais transmis à Camera
:
C’est peut-être dû à un inline des constantes par le compilateur.
Vérifions si mettre à jour l’étendue du rendu a un effet en patchant à nouveau la DLL depuis 1920x1080 vers 3840x1600 dans Camera
, puis démarrons le jeu et…
Nickel 🥳 Fini les objets qui apparaissent ou disparaissent, fini les choses étranges lorsqu’on attaque ou qu’on vise. Patcher le champ de la caméra corrige à la fois le rendu et le système de ciblage.
D’une pierre deux coups !
Puisqu’il nous reste seulement la GUI, on se concentre sur le code responsable du dessin d’objets 2D fixes à l’écran.
Encore une fois, en regardant les objets et méthodes, on identifie plusieurs systèmes interagissant pour afficher la GUI, dont GUIComponent
qui contient toute sorte de méthodes utiles pour une GUI :
On dirait une classe abstraite dont les sous-classes GUIComponent*
(GUIComponentButton
, GUIComponentTextBox
, …) héritent et réimplémentent / redéfinissent les méthodes de base.
Finalement, on tombe sur un morceau intéressant : une méthode ParseLua
. Eurêka ! 💡
Le code est construit autour d’une hiérarchie de composants génériques qui sont instanciés par et tirent leurs propriétés d’une source externe – ici, du code Lua dans le dossier Content
– et c’est exactement ce que modifient les moddeurs de gameplay (y compris GUI).
En plus, en y réfléchissant à nouveau, patcher le moteur pour modifier l’affichage de la GUI n’est pas une si bonne idée. En supposant que la logique d’affichage s’attende à ce que la source externe utilise un positionnement fixe par rapport au viewport virtuel 1920x1080 par défaut, si on la force à s’adapter à un viewport élargi, alors la GUI sera déformée. Il serait préférable de déplacer les composants afin qu’ils soient repositionnés sans être déformés.
Essayons ! Il y a cependant un problème… Le répertoire Content
est énorme :
$ find Content -type f | wc -l
4619
On cherche une aiguille dans une botte de foin…
Pour confirmer qu’on est sur la bonne piste, on va uniquement modifier quelques éléments.
Tout d’abord, on remplace les tailles d’écran 1920
par 3840
dans Scripts\UIData.lua
:
ScreenCenterX = 3840/2
ScreenCenterY = 1080/2
ScreenWidth = 3840
ScreenHeight = 1080
Deuxièmement, on remplace - 50
par + 150
pour toutes les balises de la barre de vie de la fonction ShowHealthUI
dans 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
Voyons si ça fonctionne :
Champagne ! 🥂
On remarque que la barre de vie est descendue, et que les ressources dans le coin inférieur droit ont également été déplacées.
L’interface graphique peut effectivement être modifiée directement depuis le répertoire Content
– pas de patch hexadécimal nécessaire.
Je vous épargne les détails : comme prévu, Vulkan et DirectX 32 bits semblent également compilés à partir du même code source, et peuvent être patchés à l’identique.
La seule différence est la DLL qui est responsable du moteur :
Backend | DLL du moteur |
---|---|
DirectX (64 bits) | x64/EngineWin64s.dll |
Vulkan (64 bits) | x64Vk/EngineWin64sv.dll |
(DirectX) 32 bits | x86/EngineWin32s.dll |
En revanche je n’ai toujours aucune idée de la raison d’être des autres Engine*.dll
. Voir les annexes pour plus de détails.
On a démontré que la compatibilité pour formats ultra-larges peut être moddée dans Hades, au moins dans une certaine mesure :
Content
permet de réorganiser l’interface graphique.Il reste cependant des défis à surmonter :
Par défaut, Hades utilise une mise à l’échelle statique pour afficher le jeu à des résolutions autres que 1920x1080, qui ne correspondent pas nativement à son viewport virtuel interne.
Comme vu ci-dessus, on peut patcher pratiquement n’importe quelle résolution : on a augmenté le viewport virtuel à la fois en largeur et en hauteur pour le faire correspondre à la résolution réelle de l’écran. Il s’agit en fait d’une mise à l’échelle basée sur les pixels, résultant en un angle de vue agrandi en hauteur et en largeur, et affichant plus de contenu.
Problème : la GUI n’a pas été faite pour fonctionner avec un autre viewport que 1920x1080. Avoir à réorganiser la GUI en largeur et en hauteur est beaucoup plus difficile qu’en étendant uniquement le jeu sur les côtés : si la hauteur ne changeait pas, on aurait uniquement besoin de réorganiser l’interface graphique en largeur. Il s’agit d’une mise à l’échelle Hor+.
En gardant la même hauteur virtuelle, on peut calculer la largeur virtuelle à patcher pour une mise à l’échelle Hor+ à différentes résolutions : \(\frac{Largeur}{Hauteur} * HauteurVirtuelle = LargeurVirtuelle\)
Largeur | Hauteur | Format | Largeur virtuelle |
---|---|---|---|
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 : même avec Hor+, il y aura des choix à faire concernant le positionnement de la GUI. Certains éléments seront à garder fixés sur les bords, d’autres seront garder au centre. Dans tous les cas, Hor+ rend le réarrangement plus abordable.
Malheureusement, à cause des légères variations de formats d’images 21:9, il y a plusieurs résolutions proches en termes de largeur virtuelle, mais pas tout à fait identiques. Encore une fois, patcher manuellement serait fastidieux, d’autant plus ce serait à refaire à chaque mise à jour du jeu. Peut-on l’automatiser ?
Il y a deux endroits à patcher : le viewport virtuel dans App.OnStart()
et le champ de la caméra dans Camera.Camera()
.
On pourrait essayer de calculer les offsets où patcher grâce aux fichiers PDB nous donnant les offsets des fonctions, mais ce serait plus simple de juste « reconnaître » les instructions et les patcher peu importe où elles se trouvent.
Les deux sont des instructions MOV
copiant des valeurs statiques petit-boutistes sur 4 octets – 1920 (0x780
) pour la largeur, 1080 (0x438
) pour la hauteur :
Hormis la première opérande, les deux endroits à patcher sont identiques :
c7 05 ?? ?? ?? ?? 80 07 00 00 # les deux MOV ?? 1920
c7 05 ?? ?? ?? ?? 38 04 00 00 # les deux MOV ?? 1080
Probable qu’il y ait peu ou pas d’autres instructions MOV
avec ces mêmes opérandes dans les DLLs.
Une recherche mémoire sur Ghidra pour exactement les instructions ci-dessus (où ??
agit comme un wildcard) confirme que ce sont les deux seules instances de chaque.
Par conséquent, on devrait s’en sortir avec un simple patch à base d’expressions règulières :
#!/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)
Ce script Python remplace automatiquement les deux instances de c7 05 ?? ?? ?? ?? 80 07 00 00
par c7 05 ?? ?? ?? ?? 20 0a 00 00
– 0xA20
étant l’hexadécimal pour 2592, correspondant à la largeur virtuelle nécessaire à une mise à l’échelle Hor+ en 3840x1600.
Essayons :
C’est un peu brut de décoffrage et nécessite d’être amélioré pour devenir paramétrable mais on a prouvé qu’on pouvait automatiser le patch hexadécimal.
Automatiser le modding de la GUI est un peu plus compliqué. Tout d’abord, comme on l’a fait ci-dessus, on doit fouiller les fichiers *.lua
et *.sjson
dans Content
pour identifier et choisir quoi modifier.
Quelques exemples :
Width
/Height
X
/Y
OffsetX
/OffsetY
Une fois l’intégralité des éléments à modifier cartographiée, on pourra les rajouter dans notre script automatisé.
Je n’ai pas encore fait cette catégorisation systématique, mais j’ai l’intention de m’y essayer quand j’aurai plus de temps libre.
ÉDITH 2021-08-12: c’est fait !
En fin de compte, la partie la plus difficile n’est pas le patch du moteur de rendu d’affichage comme prévu initialement, mais le peaufinage de l’interface graphique.
Si on revient sur les messages de Supergiant Games, il est maintenant évident de comprendre ce qu’ils voulaient dire : le jeu – ou plus précisément, l’interface graphique – est bien conçu pour un format 16:9 – ou plus précisément, le viewport virtuel en 1920x1080.
J’ai l’intention de continuer à travailler sur le mod automatisé, surnommé Hephaistos et publié sur GitHub.
Je pense qu’il devrait être possible de mettre en place une compatibilité pour formats ultra-larges « jouable », même si elle ne sera pas parfaite.
En tout cas, l’objectif semble atteignable compte tenu de la preuve de concept réalisée.
ÉDITH 2021-08-12: c’est fait !
Par défaut, Hades ne peut être joué qu’en 16:9 : un viewport virtuel en 1920x1080 est utilisé pour tous les calculs internes au moteur.
Le moteur de jeu peut être moddé afin d’utiliser un viewport / angle de vue différent et éviter l’ajout de bandes latérales ou horizontales, via un patch hexadécimal fonction de la résolution cible.
L’interface graphique est positionnée à la main et calibrée par rapport au viewport 1920x1080 interne.
Elle peut être moddée en modifiant le dossier Content
mais nécessite plus de travail – exemple d’implémentation partielle ci-dessus.
Rendez-vous sur Hephaistos pour essayer ! ÉDITH 2021-08-12: Hephaistos est désormais capable de patcher l’interface graphique également ! Objectif compatibilité ultra-large atteint 👌
Au choix :
/UseSideBarArt=false
aux paramètres de ligne de commande du jeu depuis Steam/Epic.UseSideBarArt = false
à votre fichier de configuration ProfileX.sjson
.Comme vu plus haut, en 3840x1600 les bandes latérales sont mal calculées et affichées décalées vers le haut :
ÉDITH 2021-10-27: bug corrigé dans la version V1.38246 du jeu 🕵️
Ce n’était pas l’objectif initial, mais comme j’étais de toute façon en pleine rétro-ingénierie de la logique de mise à l’échelle statique, j’ai trouvé le problème dans ScreenManager.Draw()
:
Note : pour plus de clarté, plusieurs variables ci-dessus ont été manuellement renommées.
Cette partie de la méthode est chargée d’afficher les illustrations latérales si nécessaire. En pseudo-code, ça donne à peu près ça :
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)
Et l’information capitale :
Si on patche en hexadécimal la valeur mémoire depuis 2,4 (0x4019999a
) vers 2,5 (0x40200000
), les illustrations latérales seront affichées sans décalage en 3840x1600 :
ÉDITH 2021-10-27: la V1.38246 vérifie si le ratio du format d’image est supérieur ou égal à 2,41 🕵️
Engine*.dll
Il y a plusieurs Engine*.dll
dans les différents backends mais seulement une est réellement chargée et utilisée par le jeu (marquée par un *
) :
Hades/
├── x64/
│ ├── Engine.dll
│ ├── EngineWin64.dll
│ └── EngineWin64s.dll*
├── x64Vk/
│ ├── EngineWin64s.dll
│ └── EngineWin64sv.dll*
└── x86/
└── EngineWin32s.dll*
Je suspecte la DLL .NET Engine.dll
d’être un reliquat du changement de moteur et d’être inutilisée.
Cette hypothèse est à priori confirmée par le fait qu’elle n’est pas présente pour les backends Vulkan et 32 bits : il est possible que la version .NET n’ait jamais été compilée que pour 64 bits.
Ensuite, aussi bien DirectX 64 bits que Vulkan 64 bits ont deux EngineWin64*.dll
natives avec du code extrêmement similaire, l’une des deux étant inutilisée. Le v
correspond évidemment à Vulkan, et il ne semble pas y avoir de différence entre les versions s
et sv
à part ça. Mais que signifie le s
? Steam ? Pas convaincu, car aussi bien les versions s
que non-s
référencent SteamWrapper.dll
(adaptateur API Steam) dans leurs imports. La seule différence que j’ai remarquée est que la version non-s
n’a pas les imports suivants, qui sont tous présents pour les versions s
et sv
:
discord_game_sdk.dll (SDK Discord)
EOSSDK-Win64-Shipping.dll (SDK Epic Online Services)
xinput9_1_0.dll (API contrôleur)
x86
est le seul sans ambiguïté, car c’est le seul avec une unique EngineWin32s.dll
(qui a les mêmes imports que les autres EngineWin64s*.dll
).
EDIT 2022-07-24: On dirait que j’avais raison et qu’aucun de ces fichiers n’étaient utilisés, car tous ont été supprimés dans une mise à jour silencieuse (pas de notes de version), ainsi que d’autres fichiers probablement uniquement utilisés sur la version .NET (MonoGame.Framework.Windows.dll
, NLua.dll
, etc.) 🕵️