← board

write(v:w:d): the fraction past ~16 digits is an approximation, padded with zeros

Measured (oracle: decimal.Decimal(float(x)), exact)

(1/3):0:30    pxx   0.333333333333333312000000000000
              exact 0.333333333333333314829616256247
              FPC   0.333333343300000000000000000000   (worse; not an oracle)

0.1:0:25      pxx   0.1000000000000000000000000
              exact 0.1000000000000000055511151

Two shapes, both silent:

Cause

The fraction is scaled by a Double multiply and then divided down, and the routine's own comment records the padding as deliberate:

  v := (x - ip) * pw + 0.5;      { pw = 10^fdigits, fdigits capped at 18 }
  ...
  i := fdigits + 1;
  while i <= decimals do     { past what a double knows: zeros, not guesses }
    write('0');

"Past what a double knows" is the false premise. A double is mant * 2^exp2 with both parts integral, so its decimal expansion is finite and exact — a denormal runs to 767 digits and every one of them is a real digit. The cap at 18 is a property of the Int64/Double scaling, not of the value.

Fix

Same route the integer part just took: expand exactly in base-10^9 limbs. The machinery is in the same unit (PxxSciMul / PxxSciSplit / PxxIntDDigits), but the fraction needs the exp2 < 0 half of the expansion (multiply by 5^k) plus digit extraction at an arbitrary offset with half-away-from-zero rounding at the cut. lib/rtl/sysutils.pas's FmtFixed is the worked example — it does exactly this for Format('%.Nf') and matches the exact value at every magnitude and precision.

Note this is the fixed path only. PXXWriteFloatSci is already exact (PxxSciDigits17).

Why it is low priority despite being silent

It only bites past ~16 fraction digits, where FPC is also wrong (differently), and no correct program can be relying on the current answer. Contrast the integer half, which broke at magnitudes a program reaches by accident.

Gate

write(v:0:d) for d up to 30 matches decimal.Decimal(float(x)) quantized half-away-from-zero, on every target; test/lib_writefloat_fixed.pas extended with the cases above.

2026-08-07 — assessed, not started; and the DECISION is now unblocked

Two findings worth recording so the next attempt starts from them.

1. The blocking decision is ready for Track U. [[decide-float-fixed-output-exact-or-fpc-17-digit-cap]] is blocked-by: bug-a-write-fixed-emits-false-digits-past-1e22 — and that ticket is done. So the decision is unblocked and is the gating question for this one: it decides whether the target is "every digit exact" or "exact below a 17-significant-digit cap, zeros above". Both are a change from today (digits 16-18 are currently wrong, not capped), but they are different implementations.

Worth noting for whoever answers it: pxx already prints the INTEGER half exactly, on all five targets, matching CPython against FPC's cap. So today the two halves of one number follow different rules — the integer part exact, the fraction approximated — which is an argument for "exact" on consistency grounds alone, independent of the FPC-parity question.

2. The fix is bigger than "port FmtFixed", because of the mirroring rule. PXXWriteFloatFixed (builtinheap.pas) carries an explicit contract in its own header: "Mirrors EmitWriteFloatFixed (x86-64), and must keep mirroring it: this is the i386 / arm32 / riscv32 route to the same output, so a program's text must not depend on which backend built it." So an exact base-10^9 expansion has to land in the hand-emitted x86-64 float writer as well as the portable body, or the targets diverge — and diverging text across backends is a worse bug than the one being fixed.

The honest shape is therefore to route both to ONE shared Pascal helper, the way the sci path already routes to PxxSciDigits17 (PXXWriteFloatSci is exact precisely because it does this). That is the real task: not a numeric tweak but removing a hand-written duplicate, which is also what stops this pair drifting again.

Left in backlog at prio 35. It wants a session that can hold the emitter change and run the cross-target gate.

Resolution (2026-08-11) — and the mirroring worry was already gone

The 2026-08-07 note said the fix had to land in the hand-emitted x86-64 float writer as well, or the backends would print different text. That is no longer true: EmitWriteFloatFixed is already a SHIM onto PXXWriteFloatFixed (the native emitter survives only as a fallback for when the helper is absent), and i386 / arm32 / aarch64 / riscv32 all call the same routine. So there is ONE implementation, and this is a numeric change after all.

New PxxFracDigits expands the fraction exactly, in the same base-10^9 limbs its integer sibling PxxIntDDigits uses — the exp2 < 0 half, multiplying by 5^k, since 2^-k = 5^k * 10^-k makes the value the integer mant*5^k with the point pushed k places left. Rounding is half-away-from-zero at the cut, decided by the first DROPPED digit (which answers both the >half and the exact-tie case, and an exact tie really is one here — nothing is scaled). It is called twice: once with emit = 0 to learn whether rounding carries into the integer part (that changes both the integer digits and the column count, 9.96:0:110.0) and once to print. PXXWriteFloatFixed lost its pw/rem/dv scaling entirely — both halves of the number now follow one rule.

Matches decimal.Decimal(float(x)) quantized half-away-from-zero on every row measured, including the ticket's two:

(1/3):0:30   0.333333333333333314829616256247   (was ...312 then zeros)
0.1:0:25     0.1000000000000000055511151        (was twenty-four zeros)
1e-320:0:325 the full 320-digit denormal expansion, ending 99999

plus 267.5:0:20 (an exact binary value — really is all zeros), the 0.5/1.5/2.5 half-away-from-zero row, a negative, a carry into the integer, a field width, and 1e20:0:2. Identical output on x86-64, i386, arm32 and aarch64; 147-file float/str/format family sweep against pinned with no unintended diffs.

riscv32 diverges on ONE row and it is not this bug: it folds 1/3 to a SINGLE-precision value (0.33333334326744079590 is float32's 1/3), which the old approximate fraction hid because nobody could print far enough to see it. Pre-existing on pinned; filed as bug-a-riscv32-folds-a-double-literal-division-to-single-precision.

test/lib_writefloat_fixed.pas extended with the fraction rows. Needs a pin before other lanes see it (compiler/builtin).

Log