_____ _ _____ _
| __ \ | | | __ \ | |
| |__) |___| |_ _ __ ___ | |__) |_ _ __| |
| _ // _ \ __| '__/ _ \| ___/ _` |/ _` |
| | \ \ __/ |_| | | (_) | | | (_| | (_| |
|_| \_\___|\__|_| \___/|_| \__,_|\__,_|
T I N Y X 8 6 D E S K T O P E D I T O RTinyRetroPad is a real, working Windows text editor — menus, dialogs, printing,
find & replace, drag-and-drop file open — whose entire executable is
2,794 bytes. That's smaller than this paragraph's screenshot.
It is written in a single 2,687-line x86 MASM assembly file,
trpad.asm,
and forked from Matt Power's Dave's Tiny Editor, itself grown from Dave Plummer's
tiny.asm / HelloAssembly.
This page walks through the entire program: the big idea, the build pipeline, the data and code layout, startup, the message loop, the window procedure, every feature, and the byte-shaving tricks that keep it tiny.
TinyRetroPad writes zero text-editing code. No buffer management, no caret
drawing, no clipboard handling, no undo stack, no word-wrap algorithm, no print
rasterization. All of that lives inside RICHEDIT50W — the modern Rich Edit
control shipped in every copy of Windows as Msftedit.dll.
The program is, in its own README's words, "basically a wrapper around the RICHEDIT50W control." Its job is reduced to three things:
SendMessage calls
(WM_CUT, EM_FINDTEXTEXA, EM_FORMATRANGE, …) aimed at that control.comdlg32, the Windows common-dialog library.
flowchart TB
subgraph EXE["trpad.exe (2,794 bytes)"]
MAIN["MainEntry<br/>message loop + Ctrl-key accelerators"]
WP["WndProc<br/>flat cmp/je message dispatcher"]
HP["Helper procs<br/>SaveFile, LoadStartupFile, PrintDoc,<br/>DoFindNext, BuildTitle, ToggleWrap ..."]
end
subgraph OS["Windows does the heavy lifting"]
RE["RICHEDIT50W control (Msftedit.dll)<br/>all editing, undo, clipboard, wrap, print render"]
CD["comdlg32 common dialogs<br/>Open / Save / Font / Find / Replace / Print / Page Setup"]
K32["kernel32<br/>CreateFile, ReadFile, WriteFile, GlobalAlloc"]
U32["user32<br/>window, menus, MessageBox, STATIC status bar"]
end
MAIN -->|DispatchMessage| WP
WP --> HP
HP -->|SendMessage EM_xxx| RE
HP --> CD
HP --> K32
WP --> U32
The file header keeps an honest ledger of what each feature cost. The bare RICHEDIT wrapper was 981 bytes; everything else was added a few hundred bytes at a time:
| Feature added | Total bytes |
|---|---|
| FILE menus | 1,375 |
| EDIT menus | 1,428 |
| Expanded FILE menus (Open / Save As) | 1,517 |
| HELP menus | 1,557 |
| FILE save-prompt flow ("Save changes?") | 1,622 |
| EDIT Time/Date | 1,668 |
| FORMAT Word Wrap | 1,694 |
| Right-click context menu | 1,779 |
| FORMAT Font dialog | 1,910 |
| EDIT Find / Find Next / Replace | 2,143 |
| FILE Print | 2,476 |
| VIEW status bar (Ln/Col) | 2,476 |
| DIALOG-based Go To | 2,686 |
| Keyboard accelerators | 2,794 |
The status bar registered as "free" because Crinkler's compression absorbed it — more on that below.
The whole build is two commands in build.bat:
ml /nologo /c /coff /Cp /IC:\masm32\include trpad.asm crinkler trpad.obj /OUT:trpad.exe /ENTRY:MainEntry /SUBSYSTEM:WINDOWS /NOINITIALIZERS /TINYIMPORT /ORDERTRIES:2000 kernel32.lib user32.lib shell32.lib comdlg32.lib gdi32.lib
flowchart LR
SRC["trpad.asm<br/>2,687 lines MASM"] -->|"ml /c /coff /Cp"| OBJ["trpad.obj<br/>COFF object"]
OBJ --> CR["Crinkler<br/>compressing linker"]
L1["kernel32.lib user32.lib<br/>shell32.lib comdlg32.lib gdi32.lib"] --> CR
CR -->|"/TINYIMPORT /NOINITIALIZERS<br/>/ORDERTRIES:2000 /ENTRY:MainEntry"| EXE["trpad.exe<br/>2,794 bytes"]
Crinkler is the secret weapon. It is not a normal linker — it's a
compressing linker from the demoscene. Instead of emitting a standard PE layout
(which costs ~1KB in headers alone), it produces an executable whose header doubles as a
decompression stub: at run time the program literally decompresses itself into memory
using a context-modeling compressor, then jumps to MainEntry. Flags that matter:
/TINYIMPORT — replaces the normal PE import table with compressed hashes of
DLL function names, resolved by the stub at startup. This is why the assembly declares
imports the unusual way it does (next section), and also why antivirus engines
distrust Crinkler output: self-decompressing code that hash-resolves its own imports
is what packers used by malware do too. The README warns Defender may eat the EXE./NOINITIALIZERS — no CRT, no constructors. The program never links a C runtime;
MainEntry is raw machine code from instruction one./ORDERTRIES:2000 — Crinkler reorders code/data sections 2,000 times searching for
the ordering that compresses best. Compression context is shared, so a "free" feature
(like the status bar's 0-byte cost) happens when new code resembles existing code
closely enough to compress to almost nothing.The file has the classic MASM two-segment shape — a .DATA section and a
.CODE section — preceded by directives and constants:
| Region | Lines | What lives there |
|---|---|---|
| Directives | 39–41 | .386, .model flat, stdcall — 32-bit flat memory, callee-cleans-stack calling convention (every Win32 API is stdcall). |
| Feature switches | 49–50 | FEAT_LINENUMBERS and FEAT_DARKMODE, assembly-time IF flags. At 0 the optional code isn't merely disabled — it is never assembled, so the baseline binary is byte-identical. |
| Constants | 62–147 | Window size, Rich Edit message numbers (EM_SETCHARFORMAT = WM_USER+68, …) defined by hand rather than pulling in a big include, and every menu command ID (IDM_FILE_OPEN = 0E202h, …). |
.DATA | 149–351 | Import declarations, all strings (menu labels, captions), global state, an in-memory dialog template, and a prebuilt CHARFORMATW selecting Courier. |
.CODE | 355–2687 | ~25 procedures: helpers, MainEntry, and WndProc. |
Instead of includelib + invoke, every API is declared as a raw pointer to
its import-address-table slot, and called through that pointer:
; declare (in .DATA) EXTERN _imp__CreateWindowExA@48 :PTR ; the @48 = 48 bytes of stdcall args ; call (anywhere) - push args right-to-left, then: call [_imp__CreateWindowExA@48]
This is exactly the shape Crinkler's /TINYIMPORT wants: each call is an indirect
call through a 4-byte slot the decompression stub fills in at startup. The decorated name
encodes the argument byte count, so the linker can match it against the real
kernel32.lib/user32.lib symbols.
| Variable | Purpose |
|---|---|
hMain / hEdit / hStatus | Window handles: frame, Rich Edit, status pane. |
CmdFile (128 bytes) | THE current file path. Shared by command-line parsing, the Open dialog, the Save dialog, load and save. One buffer, five users. |
fDirty | Set on the first EN_CHANGE; drives the title-bar and the "Save changes?" prompt. |
fWrap / fStatus | Word-wrap and status-bar toggles. |
fr / FindWhat / ReplaceWith | The shared FINDREPLACEA request used by the modeless Find and Replace dialogs. |
The Go To dialog is not an .rc resource (resources cost a whole PE section). It is a
DLGTEMPLATE built by hand in .DATA as raw dwords and words — caption
"Go To" spelled out as dw 'G','o',' ','T','o',0, an ES_NUMBER edit
box, and an OK button, referenced by the ordinal atoms 0081h (Edit) and
0080h (Button). DialogBoxIndirectParamA happily eats it from memory.
GoToTmpl LABEL DWORD dd DS_MODALFRAME or WS_POPUP or WS_CAPTION or WS_SYSMENU dw 2 ; control count dw 0,0,150,46 ; x,y,cx,cy dw 'G','o',' ','T','o',0 ; caption, hand-spelled UTF-16 ... dw 0FFFFh,0081h ; "class is the Edit atom"
Execution begins at MainEntry (line 1794) — remember, there is no C runtime.
Startup is a straight line:
flowchart TD
A["MainEntry"] --> B["GetModuleHandle - HINSTANCE"]
B --> C["LoadLibrary 'Msftedit'<br/>registers RICHEDIT50W class"]
C --> D["RegisterWindowMessage 'commdlg_FindReplace'<br/>saved in uFindMsg"]
D --> E["zero WNDCLASS, set WndProc + class name '.'<br/>RegisterClassA"]
E --> F["ParseStartupFile<br/>scan GetCommandLine for a dropped file path"]
F --> G["CreateWindowExA 800x640<br/>WS_OVERLAPPEDWINDOW or WS_VISIBLE"]
G -->|"WM_CREATE fires inside WndProc"| H["create RICHEDIT50W child<br/>set event mask, raise text limit<br/>append Save to system menu<br/>CreateNotepadMenus, STATIC status bar"]
H --> I["LoadStartupFile<br/>read file into control if path given"]
I --> J["ApplyTitle - 'name - TinyRetroPad'"]
J --> K["message loop until WM_QUIT"]
K --> L["ExitProcess"]
Details worth noticing:
"." — one byte plus terminator.
The same string is reused as both class name and initial window title in the
CreateWindowExA call, because the real title is set moments later by
ApplyTitle anyway. The comment admits: "save bytes here (seems to work)."LoadLibrary("Msftedit") — no .dll extension; Windows
appends it, the string saves four bytes. Loading the DLL is what registers the
RICHEDIT50W window class so WM_CREATE can instantiate it.ParseStartupFile hand-parses GetCommandLineA: skip the
(possibly quoted) exe path, skip whitespace, copy the first argument into
CmdFile, stripping quotes. This is the entire drag-a-file-onto-the-exe feature —
Explorer passes the dropped path as argv[1].WNDCLASS is zeroed with rep stosd (10 dwords), then only
3 fields are set: lpfnWndProc, hInstance, lpszClassName. No icon,
no cursor, no background brush — defaults are free.BuildTitle scans CmdFile for the last '\' to isolate
the filename, copies it into TitleBuf, appends " - TinyRetroPad".
(Checks for '/' and ':' were commented out — "surprised me disabling this
works" — because Explorer always hands over backslash paths.)A normal Windows app uses an accelerator table resource and
TranslateAccelerator. Resources cost bytes, so TinyRetroPad pattern-matches
keystrokes inside the message loop itself:
flowchart TD
GM["GetMessageA"] -->|returns 0 on WM_QUIT| XQ["ExitProcess"]
GM --> FD{"find/replace dialog open?"}
FD -->|"yes: IsDialogMessageA handled it"| GM
FD -->|no| KD{"WM_KEYDOWN?"}
KD -->|no| TD2["TranslateMessage<br/>DispatchMessage to WndProc"]
TD2 --> GM
KD -->|yes| FK{"F3 or F5?"}
FK -->|F3| AC["map to IDM command id"]
FK -->|F5| AC
FK -->|no| CTL{"Ctrl held?<br/>GetKeyState VK_CONTROL"}
CTL -->|no| TD2
CTL -->|"yes: N O S P F H G"| AC
AC --> SM["SendMessage WM_COMMAND to hMain<br/>Ctrl+Shift+S becomes Save As"]
SM --> GM
GetKeyState(VK_SHIFT) and
upgraded to Save As.IDM_… command ID the menus use and
re-sent as WM_COMMAND — so accelerators add zero new handler code, only the
mapping table above.IsDialogMessageA(hFindDlg, …) runs first when a modeless Find/Replace dialog is
open, so Tab and Enter work inside it — the documented requirement for modeless
common dialogs.WndProc (line 1985, ~700 lines) is the whole personality of the app. There is no
message-cracker macro, no jump table — just sequential cmp/je checks, because
short conditional branches are 2 bytes each and compress superbly:
flowchart TD
M["message arrives at WndProc"] --> C1{"WM_CREATE?"}
C1 -->|yes| H1["build RICHEDIT50W, menus,<br/>system-menu Save, status bar"]
C1 -->|no| C2{"uFindMsg?<br/>(registered FINDMSGSTRING)"}
C2 -->|yes| H2["OnFindReplaceMsg<br/>find / replace one / replace all"]
C2 -->|no| C3{"WM_SYSCOMMAND<br/>IDM_SAVE?"}
C3 -->|yes| H3["SaveFile"]
C3 -->|no| C4{"WM_COMMAND?"}
C4 -->|"EN_CHANGE notify"| H4["set fDirty, retitle with *"]
C4 -->|"menu id"| H5["flat cmp/je table:<br/>CmdFileNew ... CmdHelpView"]
C4 -->|no| C5{"WM_NOTIFY?"}
C5 -->|EN_SELCHANGE| H6["UpdateStatus - Ln, Col"]
C5 -->|"EN_MSGFILTER + WM_RBUTTONUP"| H7["ShowContextMenu"]
C5 -->|no| C6{"WM_SIZE?"}
C6 -->|yes| H8["place status bar at bottom,<br/>size edit to remaining client area"]
C6 -->|no| C7{"WM_DESTROY?"}
C7 -->|yes| H9["PostQuitMessage 0"]
C7 -->|no| H10["DefWindowProcA"]
When the frame window is born, the handler creates the Rich Edit child at size 0×0
(a deliberate cheat — WM_SIZE arrives immediately after and fixes it), sets an
event mask so the parent receives EN_CHANGE / EN_SELCHANGE / mouse
notifications, raises the edit limit to ~2GB with EM_EXLIMITTEXT, appends a
Save item to the system menu (the icon menu — that's why it arrives later
as WM_SYSCOMMAND, not WM_COMMAND), builds the menu bar, and creates the
status bar as a humble STATIC control with a sunken edge.
CreateNotepadMenus builds File/Edit/Format/View/Help with
CreatePopupMenu + two wrappers: AppendEnabled(menu, id, text) and
AppendDisabled(menu, text) — and the trick that AppendDisabled(menu, NULL)
emits a separator, so no third helper is needed. The right-click context menu
(ShowContextMenu, triggered by EN_MSGFILTER reporting
WM_RBUTTONUP) reuses the same IDs, so it costs almost nothing.
Width and height are unpacked from lParam; if the status bar is visible its 20px
are subtracted and it's parked at the bottom with SetWindowPos; the edit control
gets the rest. That's the entire layout engine.
| Command | Implementation — complete |
|---|---|
| Undo | SendMessage(hEdit, WM_UNDO) |
| Cut / Copy / Paste / Delete | WM_CUT / WM_COPY / WM_PASTE / WM_CLEAR |
| Select All | EM_SETSEL(0, −1) |
| Time/Date | GetLocalTime → GetDateFormatA + GetTimeFormatA → three EM_REPLACESEL (date, space, time) |
| Word Wrap | EM_SETTARGETDEVICE with line width 0 (wrap to window) or 0xFFFFFFFF (never wrap) |
| Font | ChooseFontW dialog → translate LOGFONT to CHARFORMATW → EM_SETCHARFORMAT. Point size ×2 = twips ÷10. This is also how Courier is set at startup, which is why gdi32 has no font code in the binary. |
LoadStartupFile and SaveFile are the only places bytes touch disk:
; load: CreateFileA(CmdFile, GENERIC_READ, OPEN_EXISTING) ; GetFileSize -> GlobalAlloc(size+1) -> ReadFile ; buf[bytesRead] = 0 -> SetWindowTextA(hEdit, buf) ; EM_SETCHARFORMAT(Courier) -> GlobalFree -> CloseHandle ; save: WM_GETTEXTLENGTH -> GlobalAlloc -> WM_GETTEXT ; CreateFileA(CmdFile, GENERIC_WRITE, CREATE_ALWAYS) ; WriteFile -> CloseHandle -> fDirty=0 -> ApplyTitle
Open and Save As are just GetOpenFileNameA / GetSaveFileNameA writing the
chosen path into the same CmdFile buffer the command line used — then the existing
load/save routines run unchanged. Open is literally a replay of app startup.
New, Open and Exit all call MaybeSaveChanges first. It returns 1 (proceed) or 0
(user cancelled — abort the command):
flowchart TD
S["MaybeSaveChanges<br/>(runs before New / Open / Exit)"] --> D1{"fDirty set?"}
D1 -->|no| OK["return 1 - continue"]
D1 -->|yes| MB["MessageBox Save changes?<br/>Yes / No / Cancel"]
MB -->|Cancel| NO["return 0 - abort command"]
MB -->|No| OK
MB -->|Yes| D2{"CmdFile has a path?"}
D2 -->|yes| SV["SaveFile"]
D2 -->|no| PK["PickSaveFile dialog"]
PK -->|user cancels| NO
PK -->|path chosen| SV
SV --> OK
Save changes?
The common Find and Replace dialogs are modeless — they don't block. They talk back
to the app through a message whose number is allocated at runtime by
RegisterWindowMessageA("commdlg_FindReplace"):
sequenceDiagram
participant U as User
participant L as Message loop
participant W as WndProc
participant D as Find/Replace dialog (comdlg32)
participant E as RICHEDIT50W
U->>W: Edit - Find (or Ctrl+F)
W->>W: InitFR fills FINDREPLACEA struct
W->>D: FindTextA(fr) - modeless, returns hFindDlg
U->>D: types text, clicks Find Next
D->>W: posts registered FINDMSGSTRING message
W->>W: OnFindReplaceMsg reads fr.Flags
W->>E: EM_FINDTEXTEXA from end of selection
E-->>W: match position or -1
W->>E: EM_EXSETSEL + EM_SCROLLCARET
Note over L,D: every loop pass calls IsDialogMessageA(hFindDlg) so the dialog gets Tab/Enter keys
OnFindReplaceMsg reads fr.Flags: FR_DIALOGTERM clears
hFindDlg; FR_REPLACEALL moves the caret to 0 and loops
find-next/EM_REPLACESEL until no match; FR_REPLACE replaces the selection
then finds the next; otherwise plain find-next. The searching itself is one Rich Edit
message, EM_FINDTEXTEXA, searching from the end of the current selection to the
end of the document.
PrintDoc shows PrintDlgA with PD_RETURNDC to get a printer device
context, computes the page rectangle in twips
(HORZRES × 1440 ÷ LOGPIXELSX), and then just loops:
PrintPage:
call StartPage
; EM_FORMATRANGE: "Rich Edit, render yourself onto this DC,
; starting at char cpMin; tell me where you stopped"
send EM_FORMATRANGE ; eax = first char of NEXT page
mov fmt.chrg.cpMin, eax
call EndPage
cmp eax, txtLen
jl PrintPage ; until the whole doc is emittedPagination, fonts, margins — all done by the control. The app draws nothing.
UpdateStatus runs on every EN_SELCHANGE:
EM_EXGETSEL (caret position) → EM_EXLINEFROMCHAR (line) →
EM_LINEINDEX (line start, so column = caret − start + 1) →
wsprintfA(" Ln %d, Col %d") → SetWindowTextA(hStatus).
Toggling it shows/hides the window and replays WM_SIZE via
RelayoutClient to reclaim the 20 pixels.
About is one MessageBoxA. View Help is ShellExecuteA("open", url) —
the browser is the help system.
Two features are wrapped in assembly-time IF blocks, so the shipping binary
contains zero bytes of them:
FEAT_LINENUMBERS — a 44px gutter painted in a WM_PAINT
handler: fill the strip, then for each visible line EM_LINEINDEX →
EM_POSFROMCHAR gives its y-pixel and TextOutA draws the number.
WM_SIZE shifts the edit control right by the gutter width.FEAT_DARKMODE — EM_SETBKGNDCOLOR for the background plus an
EM_SETCHARFORMAT with CFM_COLOR for the text, and a
CheckMenuItem tick in the View menu.Everything above is shaped by one constraint: every byte counts twice — once raw, once through Crinkler's compressor. The recurring tricks:
| Trick | Why it's smaller |
|---|---|
push 1 / pop eax instead of mov eax, 1 | 3 bytes instead of 5. Used everywhere a small constant lands in a register. |
xor eax, eax for zero | 2 bytes; also the standard "return 0 from WndProc". |
rep stosd / rep stosb to zero structs | Win32 structs (OPENFILENAME, WNDCLASS, PRINTDLG…) must be zeroed; a 4-byte string-store loop beats field-by-field writes, and the identical idiom repeated 8 times compresses to almost nothing. |
Window class named "." | 2 bytes of string, reused as the throwaway initial window title. |
"Msftedit" without .dll | LoadLibrary appends the extension for free. |
| Save lives on the system menu too | The original sub-1KB editor's only menu was one AppendMenuA onto GetSystemMenu — kept for compatibility and because it's nearly free. |
One CmdFile buffer for everything | Command line, Open dialog, Save dialog, load, save — five features, one 128-byte buffer, no path-copying code. |
| Accelerators re-send menu IDs | No second code path: Ctrl+O is File→Open. |
| Separator = disabled item with NULL text | One helper proc fewer. |
In-memory DLGTEMPLATE, no .rc | Avoids a PE resource section (hundreds of bytes of overhead). |
| Edit control created at 0×0 | The imminent WM_SIZE sizes it anyway; four push 0 are 1 byte each. |
| No GDI font code | Courier arrives via EM_SETCHARFORMAT with a prebuilt CHARFORMATW in .DATA; CreateFont and friends never get imported. |
| Repetition is free | Dozens of near-identical push/push/push/call SendMessage sequences look wasteful in source, but Crinkler's context modeling compresses repeated patterns brutally well — uniformity beats cleverness. |
The result: a complete, recognizable Notepad — File, Edit, Format, View, Help, printing, find & replace, a status bar, drag-and-drop — in fewer bytes than a 50×50 favicon.
Source: PlummersSoftwareLLC/TinyRetroPad · trpad.asm (2,687 lines) · build: MASM 14 + Crinkler · License: Apache 2.0 · Lineage: tiny.asm (HelloAssembly, Dave Plummer) → Dave's Tiny Editor (Matt Power) → TinyRetroPad.