Add an x86-64 assembly engine: 9.8x faster than Rust, 322x faster than Python by dhh · Pull Request #35 · omacom/ttfx

· GitHub

7 min read Original article ↗

@dhh @claude

Rust stays the front end: it parses arguments, reads the input and seeds
the RNG, then offers the run to the assembly engine (asm/, built by
build.rs with NASM). The engine declines anything it has not ported, before
producing output, so the Rust engine runs it instead. TTFX_ASM=0 forces
Rust; TTFX_ASM=force fails instead of falling back, which is what
tools/asm/oracle.sh uses to compare the two engines byte for byte.

Decrypt is the first effect: 206/206 oracle cases identical, 14.5x faster
at 200x50 (371 ms -> 25.5 ms).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

asm/PORTING.md is the working manual for porting effects and utilities:
conventions, PIC rules, matching the oracle's compiled float code, and how
to verify. tests/asm_diff.rs compares asm functions with their Rust
originals, starting with randint, uniform and shuffle.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

asm/utils/pycompat.asm: round_half_even, floor_div, py_mod and the
saturating f64_to_i64 cast, with pycompat.rs's non-finite rounding.

asm/utils/geometry.asm: every pub function of src/utils/geometry.rs on
packed u64 coordinates, plus the streaming ellipse. The float lowering
mirrors the compiled oracle: powf(x, 2.0) as x * x, powf(x, 0.5) as
sqrtsd with LLVM's fabs and -inf fix-ups, the sin/cos pair as sincos,
lengths through glibc hypot, and the bezier length's omitted final span.

asm/utils/color.asm: adjust_color_brightness, shift_color_towards and
random_color on the u64 color format, in the oracle's operation order.

tests/asm_diff.rs compares every function with Rust bit for bit on
boundary, degenerate and random inputs, and the color functions over all
256 values of each channel crossed with many factors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@dhh @claude

The engine now covers what effects need beyond decrypt:
- chars.asm: the character store as fixed-capacity SoA regions, fill
  characters, neighbors and the input-coordinate map
- terminal.asm: get_characters with all seven sorts and
  get_characters_grouped with all ten groupings, canvas helpers
- scene.asm: named scenes with plain, synced and eased playback, looping,
  gradients (cyclic distribution) and set_appearance
- events.asm: the event table with reentrant, append-safe dispatch
- motion.asm: paths, waypoints, segments and the exact Path.step walk
- update.asm and particles.asm: the active set, update and particle pools
- easing.asm (ported by Codex): all 31 easings, EasingTracker and
  SequenceEaser, bit-identical to the oracle over 4.3M comparisons

Decrypt runs on the general engine: 206/206 oracle cases, 32.5 ms against
Rust's 371 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Ported on cph-a2 by a Claude agent: every geometry function, pycompat
rounding and Python integer division, adjust_color_brightness,
shift_color_towards and random_color, each checked against Rust in
tests/asm_diff.rs. Motion and gradients now use these instead of the
interim path geometry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

synthgrid_build/synthgrid_next_frame transcribe src/effects/synthgrid.rs:
grid lines as consecutive added slots with an extended prefix, blocks as
one flat member array, the group tracker as an effect callback, and a
memo of the dissolve visuals. marshal packs the grid/text gradients, the
grid symbols, the generation symbols and max_active_blocks.

oracle synthgrid full: 1766 passed, 0 failed.
200x50: Rust 108 ms, asm 14 ms (best of 5, pinned).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

vhstape_build / vhstape_next_frame transcribe src/effects/vhstape.rs:
per-character glitch, restore and wave paths, the six scenes, the
glitch wave, glitch lines, noise and the line-by-line redraw, with
every RNG draw in Rust's order. Snow visuals are memoized per
(color, symbol), and lines are the engine's row groups.

oracle.sh vhstape full: 1958 passed, 0 failed.
200x50, best of 5: Rust 190 ms, asm 44 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

blackhole_build / blackhole_next_frame transcribe src/effects/blackhole.rs
draw for draw: ring selection, starfield, singularity, collapse and
explosion. Starfield visuals and their fades are made once and reused;
phase checks use a membership bitmap over the active set.

Every option is supported; tools/asm/oracle.sh blackhole full passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh

@dhh @claude

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Groups come from get_characters_grouped and are eased with the asm
SequenceEaser; each character gets one "wipe" scene whose gradient runs
from the final spectrum's first color to its mapped final color (reused
while consecutive characters share that color). The dynamic
existing-color branch is ported too. Final gradient frames outside the
i32 range are declined in marshal; values below 1 fail like Rust.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Every option is supported: all ten highlight directions, brightness,
width and the final gradient. The highlight spectrum is recomputed only
when a character's base color changes from the previous character's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Both sweeps' scenes are built in Rust's order so every RNG draw lines up;
the SequenceEaser walks the first direction's groups, then the second's.
Visuals are memoized per (symbol, color). Dynamic color handling is
ported (input-color palette with the spectrum fallback).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Transcribes src/effects/random_sequence.rs: the final gradient mapped over
the text, one 8-frame fade from the terminal background per character
(dynamic color handling included), a shuffled reveal of characters_per_tick
characters per frame. Frame durations beyond 32 bits are declined.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Rings gives every ring character one single-waypoint path per ring
coordinate, chained in a loop. The port keeps one engine path per ring
character that stands in for all of them: activating ring path k points
its waypoint at the k-th rotated coordinate and swaps in path k's own
total and origin distance, so each path's rounding history is kept
exactly. The chain becomes a callback, which keeps event lookups short.

The engine gains path_reset (motion.asm) for the "disperse" path that
Rust removes and recreates every cycle.

oracle.sh rings full: 2342 passed. 200x50, best of 5: Rust 0.577 s,
asm 0.145 s.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

A screen map keyed by (row, column) replaces the one-row buffer, so cursor
movement and overwrites behave as in input.rs: SGR colors (xterm and 24-bit,
with its widening quirk), the four ignored private modes, CSI cursor moves,
OSC and bare escapes with Rust's error text, input color frequencies,
preexisting_colors_present, and existing-color-handling always at creation.
Rust still declines ANSI input until the oracle covers it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh @claude

matrix.asm transcribes src/effects/matrix.rs: rain columns as fixed
records (pending as an index, visible as a start/end window), a pending
ring and active/full index arrays, the wall-clock rain deadline through
clock_wall, and current symbols/colors read back with visual_meta. The
dynamic existing-color branch is ported too.

Color == compares hex spellings, so the marshalling tags hex stops spelled
unlike Color::from_rgb (bits 48+, ignored by rendering and color math).

Engine: RNG_BITS53 and rng_threshold (rng.asm) decide random() < c as an
integer compare with the batch read inlined, tested against Rng::random in
asm_diff. oracle.sh gains "@global <args>" in case files; matrix uses it
for --virtual-clock, since real-clock frame counts are not reproducible.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Every option is supported, including the dynamic existing-color branch.
oracle expand full: 2726 passed; 200x50 run: Rust 109ms, asm 51ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

Every option is supported: all nine spray positions, volume, speed range,
movement easing and the final gradient. The dynamic existing-color branch is
ported too, for when the input side lands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh @claude

scattered_build/scattered_next_frame transcribe Scattered::build and
next_frame: a random start per character, one auto path home with the
SetLayer events, and a distance-synced gradient scene (the last character
gradient is reused while the final color repeats). The dynamic color branch
is ported too. Frame durations outside i32 are declined in marshal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs

@dhh

# Conflicts:
#	asm/effects/registry.asm
#	src/asm/effects.rs