Untitled - TinyRetroPad _×
  _____      _             _____          _
 |  __ \    | |           |  __ \        | |
 | |__) |___| |_ _ __ ___ | |__) |_ _  __| |
 |  _  // _ \ __| '__/ _ \|  ___/ _` |/ _` |
 | | \ \  __/ |_| | | (_) | |  | (_| | (_| |
 |_|  \_\___|\__|_|  \___/|_|   \__,_|\__,_|
 T I N Y  X 8 6   D E S K T O P   E D I T O R

How trpad.asm works

TinyRetroPad 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.

★ 2,794 BYTES — THE WHOLE APP ★

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.

For Help, press F1Ln 1, Col 1
1 — The Big Idea: don't write an editor, borrow one _×

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:

  1. Create one main window with the Rich Edit control filling its client area.
  2. Route menu clicks and keystrokes into SendMessage calls (WM_CUT, EM_FINDTEXTEXA, EM_FORMATRANGE, …) aimed at that control.
  3. Borrow every dialog — Open, Save, Font, Find, Replace, Print, Page Setup — from 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
FIG 1 · 2.7KB OF GLUE AROUND MEGABYTES OF WINDOWS

The growth log: every feature has a price tag

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 addedTotal bytes
FILE menus1,375
EDIT menus1,428
Expanded FILE menus (Open / Save As)1,517
HELP menus1,557
FILE save-prompt flow ("Save changes?")1,622
EDIT Time/Date1,668
FORMAT Word Wrap1,694
Right-click context menu1,779
FORMAT Font dialog1,910
EDIT Find / Find Next / Replace2,143
FILE Print2,476
VIEW status bar (Ln/Col)2,476
DIALOG-based Go To2,686
Keyboard accelerators2,794

The status bar registered as "free" because Crinkler's compression absorbed it — more on that below.

2 — Build pipeline: MASM + Crinkler _×

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"]
FIG 2 · THE TWO-STEP BUILD

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.
3 — Anatomy of the file _×

The file has the classic MASM two-segment shape — a .DATA section and a .CODE section — preceded by directives and constants:

RegionLinesWhat lives there
Directives39–41.386, .model flat, stdcall — 32-bit flat memory, callee-cleans-stack calling convention (every Win32 API is stdcall).
Feature switches49–50FEAT_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.
Constants62–147Window 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, …).
.DATA149–351Import declarations, all strings (menu labels, captions), global state, an in-memory dialog template, and a prebuilt CHARFORMATW selecting Courier.
.CODE355–2687~25 procedures: helpers, MainEntry, and WndProc.

Imports without an import library

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.

Global state: seven variables run the whole app

VariablePurpose
hMain / hEdit / hStatusWindow 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.
fDirtySet on the first EN_CHANGE; drives the title-bar and the "Save changes?" prompt.
fWrap / fStatusWord-wrap and status-bar toggles.
fr / FindWhat / ReplaceWithThe shared FINDREPLACEA request used by the modeless Find and Replace dialogs.

A dialog with no resources

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"
4 — Startup: MainEntry _×

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"]
FIG 3 · COLD START TO MESSAGE LOOP

Details worth noticing:

  • The window class is named "." — 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.)
5 — The message loop and hand-rolled accelerators _×

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
FIG 4 · EVERY KEYSTROKE PASSES THROUGH HERE
  • F3 → Find Next, F5 → Time/Date (exactly like classic Notepad).
  • Ctrl+N/O/S/P/F/H/G → New, Open, Save, Print, Find, Replace, Go To. Ctrl+Shift+S is detected with a second GetKeyState(VK_SHIFT) and upgraded to Save As.
  • Each hit is converted to the same IDM_… command ID the menus use and re-sent as WM_COMMAND — so accelerators add zero new handler code, only the mapping table above.
  • Ctrl+C/X/V/Z/A are not here: the Rich Edit control implements those natively. Only the chords the control can't know about are handled.
  • 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.
6 — WndProc: one big cmp/je ladder _×

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"]
FIG 5 · THE DISPATCH LADDER, TOP TO BOTTOM

WM_CREATE — the app assembles itself

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.

Menus from two 4-line helpers

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.

WM_SIZE — layout in 15 instructions

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.

7 — Feature tour: how each menu item really works _×

Edit menu: mostly one SendMessage each

CommandImplementation — complete
UndoSendMessage(hEdit, WM_UNDO)
Cut / Copy / Paste / DeleteWM_CUT / WM_COPY / WM_PASTE / WM_CLEAR
Select AllEM_SETSEL(0, −1)
Time/DateGetLocalTimeGetDateFormatA + GetTimeFormatA → three EM_REPLACESEL (date, space, time)
Word WrapEM_SETTARGETDEVICE with line width 0 (wrap to window) or 0xFFFFFFFF (never wrap)
FontChooseFontW dialog → translate LOGFONT to CHARFORMATWEM_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.

File I/O: raw Win32, one shared path buffer

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.

The "Save changes?" guard

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
FIG 6 · THE DIRTY-BUFFER DECISION TREE
TinyRetroPad×
?

Save changes?

Find / Replace: modeless dialogs and a registered message

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
FIG 7 · FIND, THE MODELESS WAY

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.

Print: the control prints itself

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 emitted

Pagination, fonts, margins — all done by the control. The app draws nothing.

Status bar: a STATIC control and four messages

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.

Help

About is one MessageBoxA. View Help is ShellExecuteA("open", url) — the browser is the help system.

Optional features: pay only if you assemble them

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_LINEINDEXEM_POSFROMCHAR gives its y-pixel and TextOutA draws the number. WM_SIZE shifts the edit control right by the gutter width.
  • FEAT_DARKMODEEM_SETBKGNDCOLOR for the background plus an EM_SETCHARFORMAT with CFM_COLOR for the text, and a CheckMenuItem tick in the View menu.
8 — The byte-shaving playbook _×

Everything above is shaped by one constraint: every byte counts twice — once raw, once through Crinkler's compressor. The recurring tricks:

TrickWhy it's smaller
push 1 / pop eax instead of mov eax, 13 bytes instead of 5. Used everywhere a small constant lands in a register.
xor eax, eax for zero2 bytes; also the standard "return 0 from WndProc".
rep stosd / rep stosb to zero structsWin32 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 .dllLoadLibrary appends the extension for free.
Save lives on the system menu tooThe 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 everythingCommand line, Open dialog, Save dialog, load, save — five features, one 128-byte buffer, no path-copying code.
Accelerators re-send menu IDsNo second code path: Ctrl+O is File→Open.
Separator = disabled item with NULL textOne helper proc fewer.
In-memory DLGTEMPLATE, no .rcAvoids a PE resource section (hundreds of bytes of overhead).
Edit control created at 0×0The imminent WM_SIZE sizes it anyway; four push 0 are 1 byte each.
No GDI font codeCourier arrives via EM_SETCHARFORMAT with a prebuilt CHARFORMATW in .DATA; CreateFont and friends never get imported.
Repetition is freeDozens 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.

2,794 bytes — smaller than this page's CSSLn 2687, Col 1
Start 📝 trpad.asm — explained --:--