The look everybody wants from film does not live in the film. It lives in a machine, and the machines are going away. A Frontier is a minilab scanner from the era when a high street needed one, and the colour it hands back was never a filter somebody designed to be pretty. It is what a shop had to hand a stranger, without being asked and without a second try.
You can chase that by eye forever. Or you can go and find out what the machine actually does.
This is the story of doing the second thing.
#The machine
The reference is a Fujifilm Frontier SP-500 and the software that actually drove it. Not the scans it produced — the pipeline itself, stage by stage, read out of the program that ran the scanner.
What makes a Frontier picture look like a Frontier picture is not the optics. It happens after the scan, in code, in a sequence of stages that each hand a number to the next one. That sequence was the target from the first day: not the scans it produced, but the thing that produced them.
#The decompiler lies to you
A decompiler is a very good liar, and it lies most convincingly when it is nearly right.
In one place it printed a clean rounding function. The machine underneath was not rounding. It was truncating — throwing the fraction away — and the two agree on almost every value you would test by hand, then disagree exactly where photographs live. Reading the actual instructions is what caught it. A rounding step in the wrong direction is worth one level on every pixel of every frame, forever, and it would have been invisible in any comparison casual enough to accept "close".
The other early lesson was quieter. The decompiler shows initialised data as zeros, so the constants simply are not there to read. They had to be pulled out of the binary itself. And once you have been shown a plausible-looking zero, you stop trusting numbers that look reasonable:
Important
A constant is never taken because it looks round.
0.5,1.0,255— these are exactly the values a tired person invents at two in the morning and never questions again. Every one of them had to come from the bytes.
Out of those two mistakes came the rule the whole project runs on: every claim carries a label — confirmed, inferred, or guess — and the label decides what may be built on top of it. A guess never becomes a constant.
#The day the journal contradicted itself
The analysis is written down while it happens, not summarised afterwards. For this phase alone that is 914 entries across 120 investigations, 73,963 lines, alongside about 2,650 commits.
My favourite pair of entries sits early in that file.
One entry works through a routine that had been assumed to produce a particular result, and concludes — with an argument, across four branches of investigation — that it was the wrong target, and that the exact shape of the thing did not matter for the goal anyway.
The next entry's headline is that a fresh dump had landed and overturned its central claim. It was the right target after all.
Both entries are still in the file. Neither was quietly edited into agreement with the other, and that is the single most useful property the journal has. A document that keeps only the conclusions is shorter, reads better, and is worth almost nothing, because no reader can tell which claims survived an attack and which were simply never attacked.
#Byte-exact, and why that was not the finish line
The first real milestone was a port of the pipeline that reproduced the original's output from the same scanner input. Not approximately: the gate was written down before the work started — within one level per pixel, at 8 bits, on frames held out of the fitting — and it came out byte-exact.
Which felt like the end, and was not.
A port that reproduces the machine by carrying the machine's own data inside it is not something you can ship. So the byte-exact port was frozen and given a different job: it became an oracle — a thing you can ask "what would the machine have done with this?" and get a trustworthy answer, millions of times, without ever shipping it.
Then the real work: build our own data, from scratch, and push it through the same stages until the oracle stops being able to tell the difference. Fit to behaviour — never load their table and resample it, which is copying with extra steps.
#The cast
The first attempt came out with a colour cast. About three levels of red, two and a half of green, one and a half of blue — small enough to argue about, far too large to pass.
It was not a mystery for long, and the answer is the most ordinary bug in engineering. The measurement had been taken from a stage that was already corrected. So the correction landed twice. Measure the thing before the adjustment, not after it, and the bias goes to zero.
The other death that week was a hypothesis I wanted to be true. The machine's sharpening looked elaborate; a plain unsharp mask is one line and everybody's first guess. Tested against the oracle it left a residual of ten levels average and a hundred and nineteen at worst. Refuted, in writing, with the numbers that killed it. The elaborate machinery was doing something after all.
#20 July
Phase B closed on 20 July 2026. Our own data, through our own implementation, agreeing with the oracle to a fraction of one level — and containing none of the manufacturer's bytes.
From the first commit on 28 June, that was twenty-two days.
The image I remember is the difference frame: the reference render on the left, then one panel per stage showing where our version disagreed with it. Panel after panel of black. It is a strange thing to be pleased by a picture of nothing.
#What travelled into this application
The colour engine in the app is generated from that reference implementation rather than retyped from it, and its test suite travels with it: 646 tests, bit-exact on the integer stages and within one unit in the last place on the floating-point ones. Every change runs them.
The copy inside the app is never edited by hand. A fix goes back upstream and is regenerated, or it dies at the next regeneration. That is also the answer to how do you know a refactor did not move the colour — it cannot, because a moved value fails before it reaches a build.
#The part I cannot solve
All of that concerns a scanner's pipeline, verified on a scanner's raw. Putting an ordinary camera capture through it is a different and much harder problem, and white light cannot solve it: under one broadband exposure the film's three dyes are not separable. Different spectra land on the same three camera numbers, and no amount of arithmetic afterwards can pull apart what the light already mixed.
That wall is the reason this application asks for three narrowband exposures instead of one. The separation is done by the light, before the maths gets a vote. What this application does not do walks the rest of that boundary.
#Why any of this is on a public forum
Because the thing you are being asked to trust is colour, and colour is the easiest thing in photography to assert and the hardest to check. "Looks like film" is not a claim, it is a mood.
So here is the trail instead: a named machine, a gate written down before the work rather than after it, a journal that keeps its own refuted entries, and a test suite that fails the build when a number moves. Judge the method. It is the part that can actually be judged.
Note
Byte offsets, function addresses, structure layouts and the manufacturer's own data files stay in the analysis repository and are not distributed. That is not modesty — publishing them would contradict the one sentence in this post that matters most, which is that what ships contains none of their bytes.