High Score
Pac-Man[1] (1980) ran on a Z80A at 3.072 MHz — no printf, no standard
library. The score display was rendered by writing digit characters into
video memory at fixed addresses; the hardware scanned those addresses and
drew what it found.
To update the score, the code computed the last digit with % 10, removed
it with / 10, and repeated until nothing remained. It wrote each character
code to the correct address.

When Namco licensed Pac-Man to Nintendo for the Famicom in 1984, the problem changed shape. The NES PPU renders by reading a name table — a 32×30 grid of tile indices. A score of 12400 meant computing the digits and writing the tile indices into the name table. Different chip, different addresses, same algorithm — and no printf.
Neither did the Atari 2600, the ColecoVision, the MSX, nor the early
arcade boards. These machines ran assembly language on CPUs with no C
runtime and no standard library. Every score counter on every platform
through the early 1980s was a printf written by hand, per project.
We are going to write ours for the same reason we wrote libtci: to understand what it does before we use it.
The implementation pages build tci_printf from the ground up — variadic
argument mechanics first, then the output primitive, then the format string
parser, then one specifier group at a time. The flags page extends it with
the full flag, width, and precision system. Start at Setup.
One Specifier at a Time
The first thing that stopped me when I sat down to write tci_printf was
output. The obvious impulse was to call puts or putchar — then I
noticed the problem: I was writing the output function. It cannot call
itself or its equivalents. There is nothing below it I can call.
The solution was write(1, buf, n) — a system call that sends bytes
directly to file descriptor 1, bypassing every library function. Every
output function in the C standard library eventually calls this. I called
it directly:
#include <unistd.h>
static int tci_putchar_fd(char c, int fd)
{
write(fd, &c, 1); /* write() needs a pointer; &c gives the address of c */
return (1); /* return byte count so the caller can accumulate it */
}The return value matters. tci_printf must return the total number of
characters written, matching libc printf exactly. Every byte that leaves
the function has to be counted. The helpers return their byte count so the
main function can accumulate it.
The second thing that stopped me was va_list. I had never written a
variadic function. va_start and va_arg looked like magic until I read
the manual: va_arg reads the next argument as the given type and advances
an internal pointer through the call stack. There is no stored type
information — the only source is the format string, which is why a wrong
specifier is undefined behaviour, not a runtime error.
int tci_printf(const char *fmt, ...)
{
va_list args;
int count;
int i;
if (!fmt) /* NULL format string: nothing to parse */
return (-1);
va_start(args, fmt);
count = 0;
i = 0;
while (fmt[i]) {
if (fmt[i] == '%' && fmt[i + 1]) { /* fmt[i+1]: guard against trailing % */
i++;
count += dispatch(fmt[i], &args);
} else
count += tci_putchar_fd(fmt[i], 1);
i++;
}
va_end(args); /* must be called before returning */
return (count);
}%s was the first specifier I implemented — call va_arg to get a
char *, walk it with tci_putchar_fd. %c was next: get an int,
cast to unsigned char, write one byte. Both worked immediately once
write was in place.
%d was where the conversion logic became real. The integer 1234 is a
32-bit value. The characters '1', '2', '3', '4' are ASCII bytes
49, 50, 51, 52. To get from one to the other, the only arithmetic available
is % 10 and / 10:
1234 % 10 = 4 → '4'
123 % 10 = 3 → '3'
12 % 10 = 2 → '2'
1 % 10 = 1 → '1'The digits arrive in reverse order. To print them correctly, you buffer
them and reverse. The same function handles every base: pass
"0123456789" for decimal, "0123456789abcdef" for hex lowercase,
"0123456789ABCDEF" for hex uppercase. %p casts the pointer to
uintptr_t, writes 0x, then runs it through the hex path.
I built each specifier separately and ran the tester after each one.
The tester compares tci_printf against libc printf byte-for-byte —
return value included. Libc printf is the oracle. No divergence passes.
The Project
Implement tci_printf as a new file in your libtci from
c01. It lives in
tci_printf.c; static helper functions live in the same file or a
separate tci_printf_utils.c.
Specifiers:
| Specifier | Handles |
|---|---|
%c | Single character |
%s | Null-terminated string; NULL pointer prints (null) |
%p | Pointer as 0x... lowercase hex; NULL prints (nil) |
%d | Signed decimal integer |
%i | Signed decimal integer (identical behaviour to %d) |
%u | Unsigned decimal integer |
%x | Unsigned hex, lowercase |
%X | Unsigned hex, uppercase |
%% | Literal % character |
tci_printf must return the total number of characters printed. An
unknown or invalid specifier should not crash the program. Flag
characters (-, 0, +, space, #), field width, and precision are
covered on the last page.
Compile with: gcc -Wall -Wextra -g -std=c99
The Tester
The companion repo contains test.sh. Clone it once, copy test.sh into
your project directory, and run it:
git clone https://github.com/thecodingidiot-com/c02-the-voice.git
cp c02-the-voice/test.sh .
bash test.shThe tester runs approximately 200 format strings through both tci_printf
and libc printf, captures both outputs and both return values, and diffs
them. Any divergence is reported. Run it after completing each page —
the specifier tests give fast feedback, and the flag tests are there
once you reach the last page.
The Companion Repo
The reference solution is at
github.com/thecodingidiot-com/c02-the-voice.
The solution/ directory contains the complete implementation:
tci_printf.c, tci_printf_utils.c, libtci.h, a Makefile, and
test.sh. It is one valid approach; any implementation that passes the
tester is equally correct.