lucidiot's cybrecluster

Lire les traductions de Qt avec Kaitai Struct

Lucidiot Informatique 2022-11-07
Les détails de la transformation des jolis rectangles de mon précédent article sur les traductions compilées de Qt en du YAML.


Quand j'avais commencé à explorer le format des traductions compilées de Qt, j'étais un peu parti dans la mauvaise direction : avant de comprendre complètement le format, je me suis jeté presque tout de suite dans l'écriture d'un schéma Kaitai Struct, ce qui m'a bien vite perdu quand je me suis heurté à des erreurs de traitement. Kaitai Struct peut être un système assez sympa pour lire des données binaires, mais je trouve qu'il gère assez mal les erreurs de lecture, ou en tout cas ne fonctionne pas forcément très bien pour explorer progressivement un format : s'il y a la moindre erreur, l'IDE en ligne affichera juste une erreur JavaScript contenant très peu d'informations et ne sera pas en mesure d'afficher ce qui a au moins pu être compris du fichier. Ça n'aide pas vraiment pour comprendre ce qui ne va pas !

Après avoir perdu pas mal de temps sur ce schéma, j'ai finalement commencé à écrire une documentation plus classique comme ce que j'avais fait pour le Well-Known Binary, ce qui est devenu à la fois un article Brainshit et une page sur mon wiki personnel. Cette écriture m'a conduit à me concentrer beaucoup plus sur le format en lui-même, et j'ai pu ensuite transformer tout ça en YAML avec beaucoup moins de difficultés. Voici donc le schéma Kaitai Struct YAML des traductions compilées de Qt, en programmation lettrée. Cet article assume bien sûr que vous ayiez auparavant lu l'article précédent documentant plus en détail le format et son contexte.

On peut commencer par un en-tête assez simple :

meta:
  id: qm
  title: Compiled Qt translations
  application: Qt
  file-extension: qm
  license: GPL-3.0-or-later
  encoding: utf-8
  endian: be

Qt n'est pas en elle-même une application, c'est une bibliothèque logicielle pour des interfaces graphiques, mais référencer chaque application existante qui utilise Qt serait une tâche impossible, donc j'en fais quand même l'application qui utilise majoritairement ce format. Mes recherches m'ont conduit à constater que l'intégralité du fichier utilise du gros-boutisme, ce qui est déjà un comportement beaucoup plus cohérent que pour d'autres formats que j'ai pu étudier jusque là, comme des formats de Windows qui utilisent majoritairement du petit-boutisme sauf à quelques endroits, ou les variantes du Well-Known Binary qui prennent en charge les petit- et gros-boutisme simultanément ; on définit donc endian: be pour ne plus avoir à s'en préoccuper nulle part ailleurs.

Structure principale

Un fichier QM est globalement assez simple ; 16 octets constants qui définissent un nombre magique, permettant de détecter qu'on a un fichier QM, puis une série de blocs qui continue jusqu'à la fin du fichier.

seq:
  - id: magic
    contents: [0x3C, 0xB8, 0x64, 0x18,
               0xCA, 0xEF, 0x9C, 0x95,
               0xCD, 0x21, 0x1C, 0xBF,
               0x60, 0xA1, 0xBD, 0xDD]
  - id: blocks
    type: block
    repeat: eos

Blocs

Un bloc se compose d'un octet représentant le type du bloc, d'un entier non signé à 32 bits indiquant la longueur du contenu du bloc en octets, puis du contenu du bloc. On peut commencer par définir une énumération pour tous les types de blocs existants :

enums:
  block_tag:
    0x2F: contexts
    0x42: hashes
    0x69: messages
    0x88: numerus_rules
    0x96: dependencies
    0xa7: language

On va ensuite pouvoir représenter le bloc. Pour le contenu du bloc, on va utiliser un switch-on, ce qui permet de modifier le type Kaitai Struct qu'on utilisera pour lire le contenu de chaque bloc en fonction du type de bloc qu'on a lu en premier.

types:
  block:
    -webide-representation: '{tag}'
    seq:
      - id: tag
        type: u1
        enum: block_tag
      - id: length
        type: u4
      - id: contents
        type:
          switch-on: tag
          cases:
            'block_tag::contexts': contexts
            'block_tag::hashes': hashes
            'block_tag::messages': messages
            'block_tag::numerus_rules': numerus_rules
            'block_tag::dependencies': dependencies
            'block_tag::language': language
        size: length

On notera aussi que j'ai ajouté un tag -webide-representation: '{tag}', ce qui fera que l'IDE en ligne de Kaitai Struct affichera le nom du type de bloc à côté de chaque bloc dans l'arbre, comme ceci :

[QM]
- magic
- blocks
  - [Block]: HASHES
  - [Block]: MESSAGES

Cela rend l'exploration de l'arbre représentant le fichier un peu plus confortable. Sachant que certains blocs peuvent être assez gros et ralentir un peu l'IDE, surtout quand on s'amuse à essayer de l'utiliser avec Firefox 52 sur Windows XP, cela permet aussi d'éviter d'en ouvrir certains inutilement et de gagner du temps.

Ensemble de contextes

Le bloc de contextes est un bloc assez étrange utilisé pour compresser les messages trouvés dans le fichier tout en fournissant une méthode d'accès rapide à ceux-ci. Il commence par ce qui est appelé une table de hachage : une liste de nombres entiers non signés à 16 bits indiquant des positions dans le bloc de contextes à partir desquelles on pourrait supposer commencer à trouver un contexte, le hachage de ce contexte étant l'index dans la liste. La liste est précédée d'un seul nombre entier à 16 bits qui indique la taille de la liste. On a ensuite une série de contextes jusqu'à la fin du bloc.

contexts:
  seq:
    - id: length
      type: u2
    - id: offsets
      type: u2
      repeat: expr
      repeat-expr: length
    - id: contexts
      type: context
      repeat: eos

Le contexte est une chaîne de caractères, précédée d'un octet non signé indiquant la longueur de ladite chaîne, et succédée d'un octet supplémentaire inutilisé si ce contexte aurait une longueur impaire, car toutes les positions de la table de hachages sont paires.

context:
  -webide-representation: '{context}'
  seq:
    - id: length
      type: u1
    - id: context
      type: str
      size: length
    - type: u1
      if: length % 2 == 0

Notez que ce dernier octet inutilisé n'a pas d'id. Un identifiant n'est jamais obligatoire dans Kaitai et indique simplement qu'on va lire un attribut, mais qu'on ne va le rendre disponible nulle part dans le code ; qu'il n'a pas de nom. C'est utile pour indiquer des données complètement inutiles.

Hachages

Le contenu d'un bloc de hachages, à ne pas confondre avec la table de hachages vue précédemment, est simple à représenter : une série de hachages. Chaque hachage a juste deux nombres à 32 bits non signés représentant le hachage et la position dans le bloc de messages qui correspond à ce hachage.

hashes:
  seq:
    - id: hashes
      type: hash
      repeat: eos
hash:
  -webide-representation: '{hash}'
  seq:
    - id: hash
      type: u4
    - id: offset
      type: u4

Messages

Le bloc de messages se constitue d'une série d'attributs. Un message est considéré comme une série d'attributs qui se termine par un attribut dit « de fin. » Au lieu de représenter donc seulement la série d'attributs, on peut s'arranger pour que Kaitai arrive à regrouper les attributs en messages en s'arrêtant à chaque fois à un attribut de fin :

messages:
  seq:
    - id: messages
      type: message
      repeat: eos
message:
  seq:
    - id: attributes
      type: attribute
      repeat: until
      repeat-until: '_.tag == attribute_tag::end'

Attribut

L'instruction repeat-until ci-dessus donne déjà une petite idée de ce qu'on va mettre dans un attribut. Chaque attribut se constitue d'un octet représentant le type de cet attribut, suivi du contenu de l'attribut, qui varie en fonction du type.

attribute:
  -webide-representation: '{tag}'
  seq:
    - id: tag
      type: u1
      enum: attribute_tag
    - id: contents
      type:
        switch-on: tag
        cases:
          # To be continued

On crée donc une nouvelle énumération pour définir tous les types d'attributs :

enums:
  attribute_tag:
    1: end
    2: source_text_utf16
    3: translation
    4: context_utf16
    5: hash
    6: source_text
    7: context
    8: comment
    9: obsolete_2

Pour remplir les cases, il nous faut identifier maintenant le contenu de chaque attribut. On constate qu'on peut en fait en regrouper plusieurs qui ont les mêmes contenus : les tags source_text_utf16, translation et context_utf16 ont tous des chaînes de caractères UTF-16, et les source_text, context et comment ont des chaînes de caractères UTF-8. On n'aura donc que 5 types de contenu d'attribut pour les 9 types d'attribut existants :

cases:
  'attribute_tag::end': end
  'attribute_tag::source_text_utf16': utf16string
  'attribute_tag::translation': utf16string
  'attribute_tag::context_utf16': utf16string
  'attribute_tag::hash': hash
  'attribute_tag::source_text': string
  'attribute_tag::context': string
  'attribute_tag::comment': string
  'attribute_tag::obsolete_2': obsolete_2

Attribut de fin

Kaitai Struct dispose, fort heureusement pour nous, de la possibilité de définir un type complètement vide. Quand on a un attribut de fin, le contenu est censé être complètement vide, donc on va utiliser cette fonctionnalité :

types:
  end: {}

Chaîne de caractères UTF-16

Les attributs de type chaîne de caractères en UTF-16 ont la particularité intéressante d'être les seuls à utiliser un entier signé pour leur longueur. Je base cette information sur un détail d'implémentation présent au moins depuis Qt 4.

D'abord, les chaînes de caractères dans Qt ont une taille maximale définie à qsizetype, un type indiquant la taille maximale supportée par la plateforme pour laquelle Qt est compilé, ce qui peut dépendre de la plateforme (qui peut avoir des tailles de nombres entiers valant 16, 32, 64, ou même 128 bits). Ce type est lui-même signé, donc même pour les autres chaînes de caractères dans ce fichier où la longueur peut être un entier non signé 32 bits, si la plateforme ne supporte pas nativement des entiers d'au moins 64 bits, elle ne prendra pas en charge le fichier du tout.

Ensuite, le code chargé de lire les fichiers QM ne prend plus en charge ni le text source UTF-16, ni le contexte UTF-16 ; il peut cependant encore lire les traductions, et heureusement ! Les traductions sont donc la seule chaîne de caractères UTF-16 prise en charge. Alors que pour toutes les autres chaînes, ce code utilise des variables de type quint32 (entier non signé 32 bits de Qt) pour lire les longueurs, il utilise un int classique, soit un entier signé 32 bits, pour le lire. Voici la ligne concernée sur Qt 4 et la même sur Qt5 et ultérieur.

Puisque rien de tout ça n'est documenté, mais qu'il est établi que Qt n'est capable que de générer et de lire des chaînes de caractères UTF-16 avec des longueurs signées dans ce format de fichier, j'ai choisi d'indiquer dans ma spécification que la longueur est donc un nombre signé.

utf16string:
  -webide-representation: '{text}'
  seq:
    - id: length
      type: s4
    - id: text
      type: str
      encoding: utf-16be
      size: length

Je dois aussi répéter le boutisme dans utf-16be, car utf-16 seul n'est pas un encodage reconnu par la plupart des langages, ou alors peut être en petit-boutisme par défaut car c'est le boutisme utilisé par Windows pour UTF-16, le plus gros utilisateur de cet encodage.

Chaîne de caractères UTF-8

Pour les chaînes de caractères en UTF-8, il y a beaucoup moins de questions à se poser. Le code source utilise un entier non-signé, et UTF-8 est l'encodage par défaut donc il n'est pas nécessaire de le re-préciser ici :

string:
  -webide-representation: '{text}'
  seq:
    - id: length
      type: u4
    - id: text
      type: str
      size: length

Hachage (obsolète)

L'attribut de hachage a été remplacé par le bloc de hachages, ce qui est plus logique pour permettre un accès plus rapide aux messages. Depuis l'introduction du bloc de hachages, cet attribut est appelé Obsolète 1 dans le code source de Qt, mais on va quand même le traiter ici et le documenter pour pouvoir supporter une plus large quantité de fichiers QM, y compris les plus anciens. Le hachage est juste un nombre entier non signé 32 bits :

hash:
  -webide-representation: '{hash}'
  seq:
    - id: hash
      type: u4

Attribut inconnu obsolète

Il ne reste enfin plus que l'attribut connu sous le doux nom de Obsolète 2, dont la définition n'est tout simplement jamais apparu dans aucune version publiée de Qt : une release a tout simplement ajouté un attribut qui était déjà obsolète. Tout ce qu'on sait, c'est que le code source ne faisait qu'ignorer cet attribut en lisant un octet et en le jetant aux oubliettes, donc on ne peut le définir que par cet octet inconnu :

obsolete_2:
  -webide-representation: '{unknown_byte}'
  seq:
    - id: unknown_byte
      type: u1

Règles de numération

Outre ma tentative de comprendre des attributs obsolètes dans les messages, c'est le bloc de règles de numération qui m'a donné le plus de fil à retordre, notamment à cause de champs qui se mesurent en bits et plus en octets. Les règles de numération sont composées d'une série de... composants. Il n'y a pas vraiment de nom spécifique pour ces composants, donc je les appelle juste des composants. Chaque règle se définit par une série de composants en se terminant soit en un composant de type « nouvelle règle, » soit par la fin du bloc de règles de numération.

Malheureusement, il n'est à ma connaissance possible que de dire de répéter soit jusqu'à la fin du bloc avec eos, qui désigne la fin du flux de données binaire (end of stream), dont on a défini la limite grâce à l'attribut length dans le contenu des blocs, soit jusqu'à ce qu'une condition soit remplie pour le composant, ce qui permettrait de détecter un composant de type « nouvelle règle » mais pas de détecter que c'est le dernier composant. On ne va donc pas pouvoir regrouper cette fois en règles, et on va devoir juste lister les composants tous ensemble :

numerus_rules:
  seq:
    - id: components
      type: component
      repeat: eos

Les composants se définissent par trois champs : d'abord, il y a toujours un octet, qui est vraiment le composant en lui-même, et qu'on pourra découper en 6 champs séparés. Ensuite, on peut trouver zéro, un ou deux octets non signés en fonction de ce que les champs du premier octet auront indiqué.

component:
  seq:
    - id: component
      type: u1
    - id: param1
      type: u1
      if: ...
    - id: param2
      type: u1
      if: ...

Il nous faut maintenant pouvoir traiter ce fameux octet. On peut pour cela prendre l'une de deux approches : soit on utilise les types dont le nom commence par b, pour bits, et qui permettent de traiter un octet bit à bit, soit on utilise des instances, c'est-à-dire des propriétés calculées, pour interpréter l'octet. J'avais déjà utilisé la première approche dans de précédentes aventures avec Kaitai Struct, mais dans ce contexte, il m'a semblé plus judicieux d'utiliser la seconde. En effet, la façon dont on doit interpréter l'octet, et le nombre de champs qu'il définit vraiment, peut varier entre juste un seul et 6 en fonction de son premier bit. J'avais eu du mal à structurer mon précédent article pour documenter correctement la complexité de cet octet, et le problème se retrouve à nouveau ici.

L'aspect le plus déterminant ici est de déterminer si le composant représente un opérateur arithmétique ou non. Un opérateur arithmétique aura 6 champs dans cet octet, ainsi qu'au moins un des deux octets optionnels en paramètre. Un opérateur non-arithmétique n'aura qu'un champ, l'opérateur lui-même, et aucun paramètre particulier.

instances:
  is_arithmetic:
    value: component & 0x80 != 0x80

On peut déterminer si un opérateur est arithmétique ou non en lisant sont tout premier bit. S'il est actif, alors un opérateur n'est pas arithmétique. Pour faire ce test, j'applique un masque, ou bit mask en anglais, à 0x80. En binaire, cela donne 0b1000000 : je recherche à ne récupérer que le tout premier octet. Le résultat de l'opération sera soit 0x80 si le bit est à 1, soit 0x00 si le bit est à 0. Comme je recherche si le composant est un opérateur arithmétique, j'utilise donc != 0x80.

Une alternative pour cette lecture pourrait être d'utiliser >> 7, un opérateur qui décalera tous les bits vers la droite de 7 bits pour ne laisser que le bit le plus à gauche, ce qui donnera 1 ou 0 ; il me faudra cependant quand même ajouter == 0 car je vais avoir besoin pour la suite de convertir ce nombre explicitement en une valeur booléenne, donc ça ne fait pas trop de différence.

On peut grâce à cette propriété calculée déjà ajouter la condition qui fera apparaître ou non le premier paramètre :

- id: param1
  type: u1
  if: is_arithmetic

Pour gérer le second paramètre, il va nous falloir obtenir l'opérateur lui-même. On sait que quand l'opérateur est non-arithmétique, c'est tout l'octet qui représentera l'opérateur, mais quand il est arithmétique, le premier bit sera toujours à zéro, puis 4 bits seront utilisés pour indiquer des options appliquées à cet opérateur, donc seuls les trois derniers bits seront utilisés pour spécifier l'opérateur. On va donc pouvoir appliquer conditionnellement un autre masque à l'octet :

operator:
  value: is_arithmetic ? component & 0b111 : component
  enum: operator

J'utilise ici un masque de 0b111, ce qui est implicitement traduit en 0b00000111, me permettant donc de lire les trois derniers bits. Je définis un enum ici, ce qui va me permettre de faire appel à une énumération pour nommer chaque opérateur :

enums:
  operator:
    0x01: equality
    0x02: less_than
    0x03: less_or_equal
    0x04: between
    0xfd: and
    0xfe: or
    0xff: new_rule

Il reste encore deux choses à faire pour pouvoir terminer de traiter les composants : d'abord, on peut maintenant ajouter une représentation textuelle pour l'IDE, en ajoutant juste que le composant de règle est désigné par son opérateur, et on peut compléter la condition du second paramètre : il n'apparaît que lors de l'utilisation de l'opérateur between, qui compare si la valeur à tester est entre deux nombres.

component:
  -webide-representation: '{operator}'
  seq:
    - id: component
      type: u1
    - id: param1
      type: u1
      if: is_arithmetic
    - id: param2
      type: u1
      if: operator == operator::between

Ensuite, on peut ajouter quatre nouvelles propriétés calculées pour lire les 4 bits spéciaux des opérateurs arithmétiques. On va utiliser à chaque fois des masques pour les lire :

not:
  value: is_arithmetic and component & 0x08 == 0x08
mod10:
  value: is_arithmetic and component & 0x10 == 0x10
mod100:
  value: is_arithmetic and component & 0x20 == 0x20
leading1000:
  value: is_arithmetic and component & 0x40 == 0x40

Dépendances

La liste des fichiers QM dont dépend ce fichier est représentée par une série de chaîne de caractères, chaque chaîne se terminant par un octet nul. Kaitai dispose de deux façons de représenter des chaînes de caractères, str qui nécessite une longueur qu'on définit soit-même, et strz qui attend un octet nul, donc on peut juste définir une répétition de strz jusqu'à la fin du bloc.

dependencies:
  seq:
    - id: dependencies
      type: strz
      repeat: eos

Langue

La langue dans laquelle ce fichier effectue ses traductions se représente simplement par une chaîne de caractères terminée par un octet nul, donc on peut réutiliser le strz :

language:
  -webide-representation: '{language}'
  seq:
    - id: language
      type: strz

Conclusion

Voilà qui conclut pour le moment la partie la plus complète de mes recherches sur les fichiers de mon GPS. Je suis allé déjà plus loin et j'avais commencé un projet C# pour faire du sens de certaines données beaucoup plus intéressantes, mais il me faudrait vraiment encore un moment avant de pouvoir en parler car il reste encore énormément de zones d'ombre à explorer. Je continuerai cependant à en parler probablement en 2023 !

Le schéma Kaitai Struct complet que j'ai découpé et expliqué dans cet article est disponible dans son intégralité sur mon wiki.


Commentaires

Il n'y a pour l'instant aucun commentaire. Soyez le premier !