Phocus 4.1.2 Color Preview Bug: A Binary Diff Investigation
Earlier today I published a post about the Phocus 4.1.2 color preview bug that affects every Mac user on every 3FR file at default settings. If you have not seen that post and you are editing Hasselblad files on Mac, read the main writeup first. It covers the symptom, the workaround (downgrade to Phocus 4.1.1), and what to do if you are affected.
This post is the technical companion for anyone who wants to see how I tracked the regression window down through a binary diff of the Phocus framework bundles. I am writing it primarily so the details exist on the public internet where other photographers, Hasselblad engineers, or future me can find them. None of what follows replaces the consumer-facing post. It is additive.
Credit where it is due: this bug was first reported by u/freddiew88 on r/hasselblad on 2026-04-09. I saw the post, reproduced the symptom on my own machines, and then spent several hours going deeper to narrow down what actually changed between Phocus 4.1.1 and 4.1.2. Everything that follows is built on their original observation.
What I already knew going in
The observable bug is summarised in the consumer post, but for the technical audience the relevant facts are:
- Phocus 4.1.2 on Mac renders the default preview for every 3FR file with visibly wrong color. The preview is flatter and less saturated than what the Hasselblad HNCS color pipeline should produce. The same file opened in Phocus 4.1.1 renders correctly.
- Exported TIFFs from Phocus 4.1.2 using the factory output preset (Adobe RGB 1998 embedded) are byte-correct. When viewed in macOS Preview, Capture One, or any other color-managed viewer, they show the full HNCS output. The export path in 4.1.2 is intact.
- Inside the Reproduction tool (which is not in the default tool layout and must be manually added), toggling Working Space from Hasselblad RGB to Hasselblad L* RGB reveals the correct preview rendering on the same file. This rules out a general failure of the preview pipeline and narrows the issue to the path that produces the default Hasselblad RGB render.
- Downgrading from Phocus 4.1.2 to Phocus 4.1.1 fixes the issue on every machine tested. The 4.1.1 binaries produce correct previews at default settings.
Item 4 is the important one for this investigation. The 4.1.1 → 4.1.2 delta is exactly the regression window. Whatever Hasselblad changed in that release is where the bug lives.
Why a binary diff was the right tool
I do not have access to Hasselblad's source code. What I do have is two binary bundles that differ in observable behaviour on identical input. The principle is simple: if Phocus 4.1.1 and Phocus 4.1.2 behave differently on the same 3FR file, they must differ in code. If they differ in code, some of that difference will be visible at the level of the exported C++ and Objective-C symbols in the shared libraries.
A symbol-level diff cannot reveal everything. Private helper functions, inlined code, and changes inside existing functions will not show up. But method additions, method removals, signature changes, and class refactors all leave visible traces in the symbol table. For a bug that looks like a color pipeline architectural change (the entire default render is wrong, not just a single code path), a symbol-level diff has a high chance of catching at least some of it.
Symbol-level analysis is also the least invasive thing you can do. There is no disassembly, no decompilation, no binary patching, no runtime hooking. I am reading the exported symbol table of a commercial application the same way a linker would read it at build time. This is legal, well-understood territory.
Have you seen the guide? I've published Essential Phocus 4.x for Mac - 72 topics across 156 pages covering everything from HNCS color science to HDR workflows. It's the reference manual Hasselblad hasn't updated since 3.8. Pay-what-you-want starting at $24.
Setting up both versions side by side
Hasselblad publishes previous version installers on their support site. I grabbed Phocus 4.1.1 and kept my already-installed Phocus 4.1.2 so both application bundles existed on disk. Both use the same framework layout inside Phocus.app/Contents/Frameworks/.
Listing the frameworks in each bundle produced identical lists: same count, same names, same layout. Any changes between versions are inside the individual framework binaries, not in the framework inventory.
For future runtime investigation I also re-signed the binaries ad-hoc with the com.apple.security.get-task-allow entitlement so I could attach lldb later, but that is a different rabbit hole. For this post, all the evidence comes from the symbol tables, which do not require re-signing.
Extracting symbols from the Mach-O binaries
The standard macOS toolchain has everything needed. For each framework binary:
nm -gU <framework_binary> | awk '{print $3}' | sort > symbols.txt
-g gives global (external) symbols. -U excludes undefined symbols so you only see what the binary defines and exports. Sorting makes the output comparable.
Then demangle with c++filt when reading the output so the C++ name mangling is human-readable:
comm -23 411.txt 412.txt | c++filt # symbols removed in 4.1.2
comm -13 411.txt 412.txt | c++filt # symbols added in 4.1.2
comm -23 prints lines unique to file 1 (removed), comm -13 prints lines unique to file 2 (added). Two commands, full removed and added lists per framework.
I ran this across every HB and every third-party framework in both bundles. Most frameworks were identical or showed trivial changes. Two stood out immediately.
Framework-level churn: HBStorage and HBImageProcessing
| Framework | 4.1.1 size | 4.1.2 size | Delta | Symbols removed | Symbols added |
|---|---|---|---|---|---|
| HBStorage.framework | 319,344 | 302,864 | -16,480 bytes | 55 | 38 |
| HBImageProcessing.framework | 7,198,896 | 7,342,240 | +143,344 bytes | 47 | 82 |
HBStorage shrunk in 4.1.2. That is unusual for a point release. Shrinkage usually indicates deleted functionality that was not replaced inline, or a consolidation where multiple methods were collapsed into one. Either way, it is a signal worth chasing.
HBImageProcessing grew by 143 KB and added 35 more symbols than it removed. That is consistent with a refactor where new classes and helper methods were introduced while some old ones were left in place or replaced with differently-named equivalents.
Every other framework either was byte-identical or changed in ways that did not obviously touch color handling. HBAppCore.framework, for example, was byte-for-byte identical between versions. Whatever changed in 4.1.2, the evidence points to HBStorage and HBImageProcessing.
The removed method that probably broke the preview
Scanning the HBStorage "removed in 4.1.2" list for anything related to color management turned up a collection of interesting deletions, but one method stood out:
CImageData::UpdateColorSync(CImageCorrection*)
This method existed in Phocus 4.1.1 as a public exported symbol. In Phocus 4.1.2 it is gone, and no method with a similar name replaces it.
The name reads as "update the Core Image color sync configuration from this image correction object." Core Image is Apple's GPU-accelerated image processing framework, and ColorSync is macOS's system color management. A method called UpdateColorSync that takes a CImageCorrection* parameter almost certainly was the bridge that re-synced the preview rendering pipeline's color configuration whenever the current image correction state changed. That is exactly the sort of glue code whose absence would produce the observed symptom: a preview pipeline that renders without picking up the correct color profile from the current image state.
I cannot prove this is the method that caused the bug. What I can say is:
- The method was public in 4.1.1. Public methods are usually called from somewhere.
- In 4.1.2 the symbol is gone. Any callers would need to have been updated or removed.
- No replacement method with the same name, signature, or obviously equivalent purpose exists in the 4.1.2 exported symbols.
- Its removal coincides exactly with the version that introduced the observable preview bug.
- The name strongly implies it was responsible for the specific type of state synchronisation that is now missing.
If I were an engineer investigating this bug with access to the source tree, the first thing I would do is check whether anything replaced CImageData::UpdateColorSync in 4.1.2 and, if not, whether that removal was the intended refactor or a code path that was accidentally orphaned during a larger rework.
The asymmetric function split in HBImageProcessing
HBImageProcessing told a consistent story. In Phocus 4.1.1, the following function existed:
CCameraImage::AddOutputFilters(
CImageProcessor*,
CImageCorrection const*,
bool,
CColorManager*
)
This one function handled the filter chain for both preview rendering and export rendering. A single code path, called from both places.
In Phocus 4.1.2 that single function is gone. In its place are two separate functions:
CCameraImage::AddOutputFiltersExport(
CImageProcessor*,
CImageCorrection const&,
CColorManager*,
CColorProfile
)
CCameraImage::AddOutputFiltersPreview(
CImageProcessor*,
CImageCorrection const*,
CColorManager*
)
Notice the asymmetry. The export variant takes a CColorProfile as an explicit fourth parameter. The preview variant does not. The export path receives a specific color profile for each render. The preview path has no equivalent mechanism.
Where does the export variant get its CColorProfile from? Two new helper methods, added in 4.1.2, visible only on the export code path:
CSaveHandler::GetOutputColorProfileForEmbedding(
CColorManager&,
COutputPreset const&,
bool,
CImageCorrection const&
)
CSaveHandler::GetOutputColorProfileForConversion(
CColorManager&,
COutputPreset const&,
bool,
std::optional<CColorProfile>
)
Both of these are CSaveHandler methods, and CSaveHandler is what handles file export. Neither one has a sibling on the preview rendering side. The export path explicitly builds a CColorProfile from the current working space and output preset, hands it to AddOutputFiltersExport, and produces correct pixels. The preview path has no such mechanism.
This is the textbook shape of a "we split a function into two variants and forgot to update one of them" refactor bug. If an engineering team split AddOutputFilters into export and preview variants because they needed to add the CColorProfile plumbing for the export side, and if they did not notice that the preview side also needed equivalent plumbing, the resulting behaviour is exactly what we see: export correct, preview broken.
The CColorProfile refactor context
The introduction of the CColorProfile class is the broader architectural change that the AddOutputFilters split fits into. In Phocus 4.1.1, color profile handling went through a collection of CColorManager methods that took CFileSpec references. Methods like these were all present in HBStorage 4.1.1:
CColorManager::SetProfile(eProfileType, std::string)
CColorManager::SetRGBProfile(CFileSpec)
CColorManager::SetCMYKProfile(CFileSpec)
CColorManager::SetGrayProfile(CFileSpec)
CColorManager::SetInputProfile(CFileSpec)
CColorManager::SetDisplayProfile(CFileSpec)
CColorManager::GetHasselbladProfile()
CColorManager::SetSharedInstance(CColorManager*) // raw pointer
In Phocus 4.1.2, every single one of those is removed. The replacements include:
CColorProfile::SetIccData(std::span<unsigned char const>)
CColorProfile::SpecialProfileToType(eSpecialProfiles)
CColorManager::SetSharedInstance(std::shared_ptr<CColorManager>) // shared_ptr
CColorManager::AddHDRWorkingSpaces()
CColorDescription::ConvertPixelSampleToProfile(
CColorManager&,
CArray<double, 3, double>,
CImageCorrection const&,
CColorProfile const&
)
The refactor pattern is clear:
- A new
CColorProfileclass was introduced that owns the actual profile data directly (as an ICC byte span) rather than referring to it by file path. CColorManagerwas updated to takeCColorProfileobjects rather thanCFileSpecreferences.- Lifetime management moved from raw pointers to
std::shared_ptr. - A new
AddHDRWorkingSpaces()method was added, consistent with ongoing HDR feature work in Phocus 4.x.
This is the kind of modernisation refactor that is legitimate and valuable in isolation. Raw pointers become shared pointers. File-path-based profile loading becomes in-memory profile objects. The API gets cleaner. The problem is not the refactor itself. The problem is that the refactor touched a large number of color-path methods and the preview rendering branch did not get the updates the export rendering branch did.
What the binary evidence proves, and what it does not
The limits of this investigation are important to state explicitly. A symbol-level diff is a narrow lens. Here is what the evidence above does and does not tell us.
What the evidence supports:
- Phocus 4.1.2 introduced a color pipeline refactor in HBStorage and HBImageProcessing.
- The refactor removed
CImageData::UpdateColorSync(CImageCorrection*)with no visibly equivalent replacement at the exported symbol level. - The refactor split
CCameraImage::AddOutputFiltersinto separate Export and Preview variants, with only the Export variant receiving the newCColorProfileplumbing. - New helper methods for constructing output color profiles (
GetOutputColorProfileForEmbedding,GetOutputColorProfileForConversion) were added only on the export path. - The architectural shape of the refactor matches the observable symptom: export produces correct color, preview does not.
What the evidence does not prove:
- That
CImageData::UpdateColorSyncis definitively the root cause. It might be. It might be a symptom of a larger refactor whose actual missing piece is somewhere else. Without source access, I cannot distinguish between these cases. - That no replacement for
UpdateColorSyncexists internally. It is possible that equivalent work was moved into an inlined helper or a private method that does not appear in the exported symbol table. A symbol diff only shows what is externally visible. - That the fix is simple. "Add the missing call" is the easy case. "The entire preview pipeline was rewired in a way that requires a deeper rework to restore correct behaviour" is the harder case. Without the source I cannot tell which one it is.
This is why the bug report I sent to Hasselblad support frames the code-level findings as "suggested starting points for engineering investigation," not as "here is the fix." I can give engineering a specific method to look at, a specific function split to verify, and the specific observable symptom that correlates with both. The root cause determination has to happen on their side with access to the actual source.
Verifying the hypothesis against observable behaviour
The strongest evidence that this is the right direction is not the binary diff itself. It is the match between the binary diff and the specific pattern of observed behaviour.
If the bug were a general failure of the preview pipeline, I would expect preview rendering to fail for all working spaces, not just Hasselblad RGB. But toggling the Reproduction tool's Working Space to Hasselblad L* RGB produces a correct preview. Only the default path is broken.
If the bug were a general failure of color management, I would expect exports to be wrong as well. But byte-level inspection of the exported Adobe RGB 1998 TIFFs shows they are correct. Only the preview render is affected.
If the bug were a problem with how Phocus reads the 3FR file, I would expect behavioural differences across different file types or cameras. But the bug is identical on every 3FR file, regardless of camera model, lens, or scene content.
The behavioural fingerprint is: "the default preview path lost its ability to pull in the correct color profile state, while the export path retained that ability through new explicit plumbing." That fingerprint matches the AddOutputFilters asymmetry directly.
The secondary observation from the Reproduction tool toggle test strengthens this further. When you toggle Working Space from Hasselblad RGB to Hasselblad L* RGB and then back again in Phocus 4.1.2, three different observers of the Working Space state behave differently:
- The RGB histogram updates on both forward and reverse toggles. It correctly reflects the current state.
- The preview display updates on the forward toggle but does not update on the reverse toggle. It stays rendered in the L* RGB state.
- The sidecar file (
.phos) does not commit a new ImageSettings history entry for the reverse toggle, and the top-levelCurrentIxstays pointing at the prior L* RGB state.
Three observers of the same state, three different behaviours. The histogram path works correctly. The preview render path and the sidecar save path are both broken on the reverse direction. That is consistent with a refactor where the state change notifications were updated for some consumers but not others.
What this refactor looks like from outside the codebase
Putting everything together, the shape of the 4.1.2 release as seen from the binary level is:
- A deliberate color pipeline modernisation refactor that introduced a new
CColorProfileclass, movedCColorManagertostd::shared_ptrlifetime, and added HDR working space support. - Updates to the export path that wired the new
CColorProfileplumbing intoAddOutputFiltersvia new helper methods, producing a correct export code path. - A parallel split of the preview path into
AddOutputFiltersPreviewthat did not receive the equivalentCColorProfileplumbing. - The removal of
CImageData::UpdateColorSync(CImageCorrection*), a method whose name strongly suggests it was the previous mechanism for refreshing the preview pipeline's color sync state, without a visible equivalent replacement.
Taken together, this looks like a refactor that was tested and verified on the export side (where the output is a file the developer can open and inspect) and not adequately tested on the preview side (where the output is what the user sees on screen, which is subtler to verify automatically).
This is not meant as a criticism of Hasselblad's engineering team. This class of bug is exactly the kind of thing that is easy to miss in a large refactor unless you have an automated regression test that compares preview rendering output to expected pixel values. Camera software companies historically do not have that kind of test infrastructure for preview pipelines, and the bug that results is invisible until someone notices their photos look wrong.
The fix, from the outside view, is to restore the equivalent of the UpdateColorSync path on the preview rendering side so the preview pipeline picks up the correct CColorProfile per render, the same way the export path does now.
Reading the symbol diffs yourself
The full text of both symbol diffs is part of the evidence package that went to Hasselblad support with the bug report. If you want to see the complete list of what was added and removed in each framework, these are the commands that produced them from scratch:
F411=phocus-4.1.1/Phocus.app/Contents/Frameworks
F412=phocus-4.1.2/Phocus.app/Contents/Frameworks
nm -gU "$F411/HBStorage.framework/Versions/Current/HBStorage" \
| awk '{print $3}' | sort > hbs-411.txt
nm -gU "$F412/HBStorage.framework/Versions/Current/HBStorage" \
| awk '{print $3}' | sort > hbs-412.txt
comm -23 hbs-411.txt hbs-412.txt | c++filt > hbs-removed-in-412.txt
comm -13 hbs-411.txt hbs-412.txt | c++filt > hbs-added-in-412.txt
Repeat for HBImageProcessing and you have the full dataset I worked from. Everything in this post is derivable from the output of those commands on the two released binaries.
Conclusion
The Phocus 4.1.2 color preview bug is, as best I can tell from the outside, a consequence of an incomplete color pipeline refactor. Hasselblad modernised their color management classes between 4.1.1 and 4.1.2, updated the export path to use the new classes correctly, and appears to have missed the equivalent update on the preview path. The removed CImageData::UpdateColorSync(CImageCorrection*) method is the single most suspicious change. The asymmetric AddOutputFilters split is the clearest structural evidence. Together they form a consistent story that matches the observable symptoms.
I have sent all of this to Hasselblad support as part of a detailed bug report, with the symbol diff files attached. If you are affected by this bug and have not already seen the main writeup, go read that post for the workaround and reproduction steps. If you want the complete symbol diffs to do your own investigation, leave a comment and I will share them.
And once more: all of the above rests on u/freddiew88's original observation that something was wrong with the default RGB rendering in Phocus 4.1.2. Without that post, I would not have known to look.
[BUY ME A COFFEE - replace with HTML card]
References
- The main writeup: "Phocus 4.1.2 Has a Critical Color Preview Regression"
- u/freddiew88 on r/hasselblad: "PHOCUS COLOR BUG? anyone else experiencing this?"
- Apple Mach-O documentation: nm(1)
- C++ name mangling and c++filt(1)
- Hasselblad Phocus download page
The Tech Behind the Frame Newsletter
Join the newsletter to receive the latest updates in your inbox.