FCSC 2026 – Challenge “Tortoise Say”

FCSC 2026 – Challenge “Tortoise Say”

. lecture : 8 minutes

Énoncé

Tortoise Say 369 points    Après Shrimp Say, voici Tortoise Say !  Tortoise Say est un système embarqué révolutionnaire utilisant comme base la technologie e-Ink. Une tortue dessinée s'empresse de vous afficher un secret.  Nous vous donnons une capture effectuée à l'analyseur logique de la communication avec l'écran lorsque la tortue était en train d'afficher le flag. L'écran utilisé est un Waveshare 2.9inch e-paper V2.  Les broches mesurées sur l'analyseur logique sont :  D0 : DIN D1 : CLK D2 : CS D3 : DC D4 : RST D5 : BUSY Arriverez-vous à retrouver le secret de la tortue ? fichier `tortoise-say.vcd.tar.xz`

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

schéma montrant le mode de communication du composant
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[1], 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.

représentation schématique d’un Arduino relié à un écran LCD
Mon expérience en électronique : brancher un Arduino à un LCD alphanumérique. Image tirée de starthardware.org/…

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 réorganise le contenu du .VCD sous forme de table de vérité :

# je me suis permis de modifier le fichier de l'énoncé 
# pour y mettre les bon noms des broches : 
# DIN, CLK, etc. au lieu de D0, D1, etc.
#
$ vcdcat tortoise-say.vcd
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, et CLK à 0
  • quand DIN change, je stocke la nouvelle valeur
  • quand DC change, j’enregistre le passage en mode « donnée » ou « commande »
  • quand CLK passe à 1, j’enregistre la valeur actuelle de DIN
  • quand CS est désactivé j’affiche les valeurs de DIN enregistré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 = ''

# commandes trouvées dans la docu
commandes = {
	'00000001': 'Driver Output control [+3 data]',
	'00010000': 'Deep Sleep Mode',
	'00010001': 'Data Entry mode setting [+1 data]',
	'00010010': 'SWRESET',
	'00100000': 'Master Activation',
	'00100001': 'Display Update Control 1 [+1 data]',
	'00100010': 'Display Update Control 2 [+1 data]',
	'00100100': 'Write RAM',
	'00110010': 'Write LUT register [+30 max. data]',
	'01000100': 'Set RAM X [+2 data]',
	'01000101': 'Set RAM Y [+4 data]',
	'01001110': 'Set RAM X address counter [+1 data]',
	'01001111': 'Set RAM Y address counter [+2 data]',
}

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':
				# on ignore
				pass

			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_DIN

			case 'logic.CS':
				chip_selected = True if s_value=='0' else False
 
				if not mode_data:
					# identification commande
					i_command = commandes.get(i_data, '???')

				if not chip_selected:
					# la puce est désélectionnée, on affiche l’octet
					print(f'{i_data}  ', end='')
					print('data') if mode_data else print(f'command  {i_command}')
					i_data = ''

			case _:
				print('WTF')

Ce 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, et 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”[2]. 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 toujours exactement 4 736 octets « données ». Chaque octet représentant 8 pixels on trouve bien : 4 736 × 8 = 37 888 = 128×296, 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 ! 🥳

j’en ai fait un gif animé, parce que pourquoi pas.

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


  1. files.waveshare.com/upload/e/e6/2.9inch_e-Paper_Datasheet.pdf ↩︎

  2. ainsi qu’à “the address counter is updated in the Y direction.” mais j’avoue ne pas savoir ce que ça veut dire ↩︎