Mod ultrawide pour Hades

jeux vidéo

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.

Contexte

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 :

Hades avec illustrations latérales en 3440x1440

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 :

Hades avec illustrations latérales décalées en 3840x1600

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 :

Hades avec bandes latérales noires en 3840x1600

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

La position de Supergiant Games

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 :

  • À l’heure actuelle, elle ne serait utilisée que par un sous-ensemble extrêmement minoritaire du public cible.
  • Pas de compatibilité du tout, c’est toujours mieux qu’une mauvaise compatibilité : la même minorité serait très remontée (à juste titre) si le jeu était annoncé compatible sans l’être dans les faits.

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 😇

Recherche de mods

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

Analyse initiale

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

Les backends proposés au lancement d'Hades 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à.

Décompilation .NET avec dnSpy

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 :

  • Le jeu démarre et initialise un viewport virtuel statique en 1920x1080.
    • Le viewport virtuel est utilisé pour tous les calculs internes au moteur.
  • Il détecte la résolution d’affichage réelle – grâce au matériel ou grâce aux paramètres fournis par le joueur.
  • Il transforme ensuite le viewport virtuel vers la résolution d’affichage réelle par mise à l’échelle statique :
    • Si la résolution est en 16:9, le viewport est affiché tel quel.
    • Si la résolution a un format plus étroit (e.g. 4:3), le viewport est encadré horizontalement.
    • Si la résolution a un format plus large (e.g. 21:9), le viewport est encadré latéralement.

Essayons de patcher la DLL en changeant le viewport virtuel par défaut depuis 1920x1080 (0x780x0x438 en hexadécimal) vers 3840x1600 (0xF00x0x640) :

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 :

Décompilation de `EngineWin64.dll` avec 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.

Changement de moteur sur Hades

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.

Décompilation native avec Ghidra

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 :

  • L’interface graphique n’a pas suivi le viewport : elle est restée verrouillée dans le coin supérieur gauche en 1920x1080.
  • Le système de ciblage se comporte étrangement : il semble calculer où cibler en fonction de la position de la souris par rapport au centre du coin supérieur gauche en 1920x1080 (peut-être par rapport au centre de la GUI ?). C’est très visible lorsqu’on attaque ou qu’on vise.
  • Le moteur de rendu semble également verrouillé en 1920x1080 : on peut voir des objets apparaître et disparaître ostensiblement sur les bords de l’écran. Mais contrairement aux deux autres, il est correctement centré sur le viewport et non sur le coin supérieur gauche.

Analyse

En résume, pour le backend x64 (DirectX) :

  • Les trois 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.
  • Bruteforcer le viewport virtuel est suffisant pour contourner les bandes latérales / horizontales, mais il reste à :
    • Corriger la GUI afin qu’elle se mette à l’échelle et soit centrée sur le viewport.
    • Corriger le système de ciblage pour qu’il agisse correctement par rapport au centre du viewport.
    • Corriger le rendu des objets pour les afficher correctement dans tout le viewport.

En prenant un peu de recul, il me semble logique d’aborder en priorité le troisième point :

  • Probable que la GUI puisse être corrigée. Il s’agit d’illustrations 2D affichées à l’écran : il y aura un morceau de code quelque part responsable d’afficher la GUI à une position fixe, et qui devrait être bruteforçable de la même manière.
  • Probable que le système de ciblage puisse être corrigé. Même raisonnement : il y aura un morceau de code quelque part responsable de définir le point par rapport auquel la position de la souris est prise en compte pour les calculs de ciblage.
  • En revanche, corriger le rendu d’objets peut se révéler beaucoup plus difficile. En fonction de l’implémentation de la logique de rendu, corriger le moteur peut soit être bruteforçable également, soit nécessiter de complètement réimplémenter le moteur de rendu. Dans ce cas-là, corriger les deux autres en premier serait une perte de temps.

Par conséquent, nous allons commencer par chercher le code responsable du rendu des objets.

Double kill

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 :

Décompilation de `EngineWin64s.dll` avec Ghidra - `Camera.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 !

Jamais deux sans trois ?

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 :

Décompilation de `EngineWin64s.dll` avec Ghidra - Méthodes de `GUIComponent`

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 :

`EngineWin64s.dll` avec patch du viewport+caméra+GUI 3840x1600

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.

Autres backends

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.

Analyse (bis)

On a démontré que la compatibilité pour formats ultra-larges peut être moddée dans Hades, au moins dans une certaine mesure :

  • Patcher en hexadécimal le viewport virtuel et le champ de la caméra permet d’afficher le jeu à pratiquement n’importe quelle résolution sans bandes latérales ni horizontales, sur tous les backends.
  • Éditer le répertoire Content permet de réorganiser l’interface graphique.

Il reste cependant des défis à surmonter :

  • La GUI utilise des illustrations de taille fixe : on doit trouver des solutions pour certains composants gênants tels que le masque noir en surimpression dans les vidéos ci-dessus, soit en les redimensionnant indépendamment, soit en les remplaçant par des illustrations sur-mesure, soit en les supprimant carrément.
  • La GUI se compose de nombreux composants indépendants : les réorganiser manuellement un par un est extrêmement fastidieux, et devrait de toute façon être refait pour chaque résolution (3440x1440, 3840x1600…) et à chaque mise à jour.

À propos de la mise à l’échelle

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 ?

Patch hexadécimal automatisé

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

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

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

Réorganisation automatisée de la GUI

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 :

  • Tailles : Width/Height
  • Positions : X/Y
  • Décalages relatifs : OffsetX/OffsetY
  • Méthodes qui calculent des valeurs dérivées des éléments ci-dessus.

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 !

Conclusion

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 !

tl;pl

Hades avec mise à l'échelle Hor+ tournant en 3840x1600 (pas de bandes latérales)

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 👌


Annexes

Désactiver les illustrations latérales

Au choix :

Hades avec bandes latérales noires en 3840x1600

Correction du pillarboxing en 3840x1600

Comme vu plus haut, en 3840x1600 les bandes latérales sont mal calculées et affichées décalées vers le haut :

Hades avec illustrations latérales décalées en 3840x1600

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

Décompilation de `EngineWin64s.dll` avec Ghidra - `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 :

  • Par défaut (non montré sur la capture d’écran) :
yOffset = 0
height = viewport.height
  • Si le ratio du format d’image est supérieur ou égal à 2,4, alors :
yOffset = -400
height = (texture.height / texture.width) * viewport.width
  • Ensuite, ces deux valeurs sont utilisées pour créer les rectangles dans lesquels les illustrations seront affichées :
left = (0, yOffset, viewport.width, height)
right = (fullViewport.width, yOffset, -viewport.width, height)

Et l’information capitale :

  • En 3440x1440, le format d’image est 21,5:9, soit à peu près 2,39 de ratio. Tout roule.
  • En 3840x1600, le format d’image est 21,6:9, soit tout pile 2,4 de ratio. Woups 🙃

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 :

Hades avec illustrations latérales corrigées 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 🕵️

Le mystère des 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.) 🕵️

Article précédent Article suivant