Énoncé

Premiers pas
Je commence par ouvrir le fichier fourni, un document texte de 20 Mo imbitable :
$version FCSC2026 $end
$date Sun, 01 Feb 2026 01:00:00 GMT $end
$timescale 1ns $end
$scope module logic $end
$var wire 1 ! DIN $end
$var wire 1 " CLK $end
$var wire 1 # CS $end
$var wire 1 $ DC $end
$var wire 1 % RST $end
$var wire 1 & BUSY $end
$upscope $end
$enddefinitions $end
#0
0!
#0
0"
#0
0#
#0
0$
#0
0%
#0
0&
#220168
1#
#333968
1&
#333968
0&
(…)2,7 millions de lignes comme ça
Je découvre qu’il s’agit du format VCD pour “Value Change Dump” (défini dans le standard IEEE 1364-2005 🤓). Heureusement pour moi il existe des bibliothèques Python pour lire ce truc ; je choisis vcdvcd (pypi.org/project/vcdvcd) qui est fourni avec –je cite– “the nifty vcdcat VCD command line viewer”.
Je m’intéresse ensuite à l’écran utilisé, le “Waveshare 2.9inch e-paper V2”.
Comme tout composant électronique, ses spécifications sont disponibles en ligne :
waveshare.com/wiki/2.9inch_e-Paper_Module_Manual#Overview

Communication Method
CSB (CS): Slave chip select signal, active at low level. When it is at low level, the chip is enabled.
SCL (SCK/SCLK): Serial clock signal.
D/C (DC): Data/command control signal, write command (Command) when the level is low; write data (Data/parameter) when the level is high.
SDA (DIN): Serial data signal.
Timing: CPHL=0, CPOL=0, i.e. SPI mode 0.
Note: For specific information about SPI communication, you can search for information online on your own.
D’expérience, pour avoir bidouillé un peu un Arduino, je sais qu’un composant (ici l’écran) ne lit ses entrées que quand le signal “chip select” (CS) est actif et à chaque fois que le signal d’horloge (CLK pour clock) passe à 1.
D’après la datasheet, je vois que BUSY, le seul signal capturé venant de la puce, est envoyé pour indiquer que des traitements sont en cours.DIN représente les données envoyées au composant, et je ne sais pas ce que fait RST mais je suppose que c’est suffisant pour commencer l’analyse.

Analyse du .VCD
L’utilitaire vcdcat est en effet “nifty”, il n’y a même pas besoin de créer un script Python : la commande $ vcdcat tortoise-say.vcd réorganise le contenu du .VCD sous forme de table de vérité :
0 time
1 logic.DIN
2 logic.CLK
3 logic.CS
4 logic.DC
5 logic.RST
6 logic.BUSY
0 1 2 3 4 5 6
=============
0 x x x x 0 x
0 x 0 x x 0 x
0 x 0 0 x 0 x
0 x 0 0 0 0 x
0 x 0 0 0 0 0
0 0 0 0 0 0 0
220168 0 0 1 0 0 0
333968 1 0 1 0 0 0
333968 0 0 1 0 0 0
333968 0 0 1 0 0 1
10334296 0 0 1 0 0 0
12334320 1 0 1 0 0 0
12334320 0 0 1 0 0 0
12334320 0 0 1 0 0 1
172334464 0 0 0 0 0 1
172334848 0 1 0 0 0 1
(…)1,3 millions de lignes comme ça
J’ai perdu un peu de temps à essayer d’exploiter ça, en l’ouvrant dans Calc (message d’erreur « dépassement du nombre maximum de lignes par feuille » 😅), en filtrant les lignes où CLK est à 1… Bref, je faisais fausse route.
Analyse des deltas
vcdvcd peut exporter uniquement les changements (« delta ») entre 0 et 1, ce qui s’avère être beaucoup plus utile ici. $ vcdcat --deltas tortoise-say.vcd :
(…)
222384936 1 logic.CS
222385064 0 logic.DC
222385208 0 logic.CS
222385592 0 logic.DIN
222385592 1 logic.CLK
222385856 0 logic.CLK
222386104 1 logic.DIN
222386104 1 logic.CLK
222386360 0 logic.CLK
222386608 0 logic.DIN
222386608 1 logic.CLK
(…)autant de lignes que dans vcdcat.txt
J’ai créé un script python pour analyser ce nouveau fichier et interpréter les signaux comme le ferait la puce :
- j’ignore les changements de signaux
RST,BUSY, etCLKà 0 - quand
DINchange, je stocke la nouvelle valeur - quand
DCchange, j’enregistre le passage en mode « donnée » ou « commande » - quand
CLKpasse à 1, j’enregistre la valeur actuelle deDIN - quand
CSest désactivé j’affiche les valeurs deDINenregistrées, en indiquant s’il s’agit de données ou d’une commande (et je donne le nom de la commande appelée, tant qu’à faire 😎 (ce qui est probablement une perte de temps, mais bon on ne se refait pas))
#!/usr/bin/env python
# valeurs internes
chip_selected = False
mode_data = False
i_command = ''
i_DIN = ''
i_data = ''
i_DC = ''
with open('vcdcat-deltas.txt', 'r') as file:
for line in file:
s_time, s_value, s_pin = line.split()
match s_pin:
case 'logic.BUSY' | 'logic.RST':
# ignore
pass
case 'logic.CS':
chip_selected = True if s_value=='0' else False
# commande
if not mode_data:
match i_data:
case '00000001':
i_command = 'Driver Output control [+3 data]'
# Booster Soft start Control
case '00010000':
i_command = 'Deep Sleep Mode'
case '00010001':
i_command = 'Data Entry mode setting [+1 data]'
case '00010010':
i_command = 'SWRESET'
case '00100000':
i_command = 'Master Activation'
case '00100001':
i_command = 'Display Update Control 1 [+1 data]'
case '00100010':
i_command = 'Display Update Control 2 [+1 data]'
case '00100100':
i_command = 'Write RAM'
case '00110010':
i_command = 'Write LUT register [+30 max. data]'
case '01000100':
i_command = 'Set RAM X [+2 data]'
case '01000101':
i_command = 'Set RAM Y [+4 data]'
case '01001110':
i_command = 'Set RAM X address counter [+1 data]'
case '01001111':
i_command = 'Set RAM Y address counter [+2 data]'
case _:
i_command = '???'
if not chip_selected:
print(f'{i_data} ', end='')
print('data') if mode_data else print(f'command {i_command}')
i_data = ''
case 'logic.DC':
# “When the pin is pulled HIGH, the data will be interpreted as data. When the pin is pulled LOW, the data will be interpreted as command”
mode_data = True if s_value=='1' else False
case 'logic.DIN':
i_DIN = s_value
case 'logic.CLK':
if s_value == '1':
i_data += i_DINCe script transforme la liste des deltas produits par vcdcat en une suite d’octets, tels qu’ils ont été « compris » par le composant :
00010010 command SWRESET
00000001 command Driver Output control [+3 data]
00100111 data
00000001 data
00000000 data
00010001 command Data Entry mode setting [+1 data]
00000011 data
01000100 command Set RAM X [+2 data]
00000000 data
00001111 data
01000101 command Set RAM Y [+4 data]
00000000 data
00000000 data
00100111 data
00000001 data
(…)
00000000 data
00000000 data
00000000 data
00111111 command ???
00100010 data
00000011 command ???
00010111 data
00000100 command ???
01000001 data
00000000 data
00110010 data
00101100 command ???
00110110 data
00100100 command Write RAM
11111111 data
11111111 data
11111111 data
11111111 data
(…)76 005 lignes comme ça
Je n’ai pas pu identifier certaines commandes soit à cause d’un bug dans mon script, soit parce que j’ai mal lu la documentation, mais ce que j’obtiens est cohérent : les commandes qui doivent être suivies d’un certain nombre d’octets “data” le sont bien.
Je remarque aussi que les signaux envoyés semblent respecter le mode de fonctionnement de l’écran : on commence par transmettre des instructions de configuration de l’affichage (Data Entry mode setting, Set RAM X, Set RAM Y, etc.) avant de balancer énormément d’octets « donnée ».
Je vois qu’on envoie la commande Data Entry mode setting suivie de l’octet 00000011 qui correspond, toujours d’après la documentation, à l’option “Y decrement, X decrement”. Je vois aussi que les données sont entrecoupés de commandes Write RAM, ce qui correspond à une demande d’affichage :
Write RAM: “After this command, data entries will be written into the RAM until another command is written. Address pointers will advance accordingly.”
Extraction de l’image
Confiant d’avoir trouvé les données d’affichage, je commence un nouveau script Python capable d’en faire une image.
J’ai eu un moment de doute ne sachant pas si l’affichage était configuré en mode « noir et blanc » ou en « niveaux de gris », j’ai décidé d’essayer en N&B (où 1 est noir et 0 et blanc) dans un premier temps, en me disant que le ou la créatrice de ce challenge ne serait pas aussi méchante🙂.
(je ne l’avais pas remarqué sur le coup, mais entre chaque octet « commande » j’avais exactement 4736 octets « données », avec 8 pixels par octets on trouve bien : 4 736 × 8 = 37 888 = 128×296, exactement la résolution de l’écran.
Je sais que l’écran à une résolution de 128(H)×296(V) et qu’on va envoyer les bits en partant de (128,296) vers (0,0), il ne me reste plus qu’à faire un script python qui lit les octets de données et qui ajoute un pixel soit blanc soit noir à une image 128×296, grâce à la bibliothèque Pillow :
#!/usr/bin/env python
from PIL import Image
# Image parameters
width, height = 128, 296
image = Image.new("RGB", (width, height), 'purple')
x = width-1
y = height-1
with open('write_RAM.txt', 'r') as file:
for line in file:
s_bin, _ = line.split()
for chiffre in s_bin:
couleur = (0, 0, 0, 0) if chiffre == '0' else (255, 255, 255, 255)
image.putpixel((x,y), couleur)
if x>0:
x-=1
else:
# ligne suivante
x = width-1
y = height-1 if y==0 else y-1
image.save("Tortoise_say_extraction.bmp")Mon code a fonctionné du premier coup (j’en suis le premier étonné, croyez bien) et a produit l’image suivante (tournée de 90°) :

…qui est l’image de l’énoncé. 😐
Après un moment de panique où je me suis demandé si les données pouvaient représenter une image en « noir et blanc » ET une autre en « niveaux de gris », ou si le flag était caché dans les différences de pixels entre mon image et celle de l’énoncé, je me suis souvenu que les données envoyées représentaient beaucoup plus qu’une image, et que le flag était peut être affiché puis recouvert par le “good luck!”
Après une petite modification de mon script pour qu’il enregistre toutes les images intermédiaires : BINGO j’avais trouvé le flag ! 🥳

🚩 FCSC{49a3efe9bbf4f610b05a133ad6156ba7080c35} validé, pour 369 points
(j’étais la 55e personne à résoudre ce problème) 😎