Hacking the Splash Screen of Houdini
Imagine that one day you stumble, almost by sheer chance, into a fascinating repository that teaches you how to override the Splash Screen of your favourite application via some intricate pointer and LD_PRELOAD magic. Then imagine realizing that the person that made the repo is technically (almost) an ex colleague of yours, and the code is now 8 years old.
Could you resist the temptation to reproduce their teachings ?
I couldn't.
Preliminary research
The repository is https://github.com/heavyimage/OverrideQtSplashscreen, and the colleague is heavyimage
(now doing a PhD in Cybersecurity - the signs were there, one might say!).
The code shows how to tweak the Splash Screen for The Foundry's Nuke, but after all, Houdini (my favourite 3d application) also uses Qt.. so why not try to reproduce those same dark arts there ?
Premise
Compared to other DCCs, Houdini always had a more involved setup to be able to run it from a shell. Before entering the LD_PRELOAD rabbit hole, let's try to just a few of the moving bits at play:
# First, we source the first setup script
$ cd /opt/hfs20.5.445 && source houdini_setup && cd -
# This adds 'houdini' in the $PATH
$ type -a houdini
houdini is /opt/hfs20.5.445/bin/houdini
# ..which is just another bash wrapper
$ file /opt/hfs20.5.445/bin/houdini
/opt/hfs20.5.445/bin/houdini: Bourne-Again shell script, ASCII text executable
# ..which sources another shell script
$ grep source /opt/hfs20.5.445/bin/houdini
source "${APP_DIR}/app_init.sh" "${APP_DIR}"
$ find /opt/hfs20.5.445 -name app_init.sh
/opt/hfs20.5.445/bin/app_init.sh
# ..which sources another `houdini_setup` !
$ cat /opt/hfs20.5.445/bin/app_init.sh | grep houdini_setup
if [ -e "${APP_DIR}/../houdini_setup" ]; then
cd "${APP_DIR}/.." && source houdini_setup -q && cd "${CUR_DIR}"
cd "${APP_DIR}/../.." && source houdini_setup -q && cd "${CUR_DIR}"
Well, let's stop here - there's definitely more than a few layers of indirection going on.. maybe let's just run Houdini and see what's the actual binary running, it's should be easier.
$ houdini &
$ ps aux | grep houdini
vv 1109835 33.0 0.7 2647336 484224 ? Rsl 11:38 0:01 /opt/hfs20.5.445/bin/houdini-bin
vv 1109979 0.0 0.0 18884 2240 pts/13 S+ 11:38 0:00 grep --color=auto houdini
Neat.
Out of curiosity, how many shared libraries does that binary link against ?
$ ldd /opt/hfs20.5.445/bin/houdini-bin | wc -l
181
Wow!
Anyway, let's go back on track now that we've found the main binary.
Let's confirm that the Splash Screen internals are still the way heavyimage
described them almost a decade ago, by looking around the installation directory:
$ find /opt/hfs20.5.445 -iname '*splash*'
/opt/hfs20.5.445/houdini/pic/pilotpdgsplash.pic
/opt/hfs20.5.445/houdini/pic/hindiesplash.png
/opt/hfs20.5.445/houdini/pic/pilotpdgsplash.png
/opt/hfs20.5.445/houdini/pic/houdinincsplash.png
/opt/hfs20.5.445/houdini/pic/houdiniedusplash.pic
/opt/hfs20.5.445/houdini/pic/hescapesplash.pic
/opt/hfs20.5.445/houdini/pic/hescapesplash.png
/opt/hfs20.5.445/houdini/pic/hindiesplash.pic
/opt/hfs20.5.445/houdini/pic/houdinincsplash.pic
/opt/hfs20.5.445/houdini/pic/houdiniedusplash.png
/opt/hfs20.5.445/houdini/pic/houdinisplash.png
/opt/hfs20.5.445/houdini/pic/houdinisplash.pic
/opt/hfs20.5.445/houdini/help/videos/vellum_crownsplash.mp4
/opt/hfs20.5.445/toolkit/include/QtWidgets/qsplashscreen.h
/opt/hfs20.5.445/toolkit/include/QtWidgets/QSplashScreen
$ cat /opt/hfs20.5.445/toolkit/include/QtWidgets/QSplashScreen
#include "qsplashscreen.h"
$ grep setPixmap -A 10 /opt/hfs20.5.445/toolkit/include/QtWidgets/qsplashscreen.h
void setPixmap(const QPixmap &pixmap);
const QPixmap pixmap() const;
void finish(QWidget *w);
void repaint();
QString message() const;
public Q_SLOTS:
void showMessage(const QString &message, int alignment = Qt::AlignLeft,
const QColor &color = Qt::black);
void clearMessage();
Seems like it!
We found the main header file, specifying the signature of the setPixmap
static method:
void setPixmap(const QPixmap &pixmap);
As one final check, let's see in which library the setPixmap
symbol is defined:
# Let's catch a very broad net first..
$ find /opt/hfs20.5.445 -type f -name '*.so.*' -exec bash -c 'nm -CDe {} | grep --color QSplashScreen::setPixmap' \; -print
0000000000300240 T QSplashScreen::setPixmap(QPixmap const&)@@Qt_5
/opt/hfs20.5.445/dsolib/libQt5Widgets.so.5.15.2
# Found it!
$ nm --extern-only --demangle --dynamic /opt/hfs20.5.445/dsolib/libQt5Widgets.so.5 | grep 'QSplashScreen::setPixmap'
0000000000300240 T QSplashScreen::setPixmap(QPixmap const&)@@Qt_5
# Let's take a note of the mangled name, we'll need this later:
$ nm --dynamic /opt/hfs20.5.445/dsolib/libQt5Widgets.so.5 | grep 0000000000300240
0000000000300240 T _ZN13QSplashScreen9setPixmapERK7QPixmap@@Qt_5
Executing the shared library injection
Cool, so now we found the signature of the setPixmap
method, and we confirmed that this function is exported in the libQt5Widgets.so.5.15.2
library with the name _ZN13QSplashScreen9setPixmapERK7QPixmap
.
The next step is to build our own library that overrides the implementation of that method to provide our own!
On Linux, this can be easily done via the LD_PRELOAD
env var. On macOS I still haven't managed to successfully override functions, despite being able to use the DYLD_INSERT_LIBRARIES
env var to force preloading my own shared library. I'll have to do a bit more research and try out symbol interposing (see https://stackoverflow.com/a/34120249)
If you never heard of the LD_PRELOAD
, here's a quick TL;DR from man ld.so
:
LD_PRELOAD A list of additional, user-specified, ELF shared objects to be loaded before all others. This feature can be used to selectively override functions in other shared objects.
Cool cool cool. Now, to build our own shared library and link it against Qt, we'll need qmake
. That's because "one does not just pass -lQtWidgets" and call it a day, due to how Qt needs to resolve the Q_OBJECT
macro.
From https://doc.qt.io/archives/qt-4.8/moc.html :
The Meta-Object Compiler, moc, is the program that handles Qt's C++ extensions. The moc tool reads a C++ header file. If it finds one or more class declarations that contain the Q_OBJECT macro, it produces a C++ source file containing the meta-object code for those classes. Among other things, meta-object code is required for the signals and slots mechanism, the run-time type information, and the dynamic property system.
So, because it's 2025, let's try to build all of this on Docker. This way we can avoid polluting our system with an installation of qmake
and the related Qt libs (yes, I'm a fan of using Docker to build random stuff).
Note: you'll need the Docker BuildX plugin (on Debian,
sudo apt-get install docker-buildx-plugin
).
Let's start with one of the beautiful Docker Images provided by the ASWF, for example aswf/ci-baseqt:2022.2
(https://hub.docker.com/r/aswf/ci-baseqt/tags?page=1&name=2022.4). We'll need Qt 5.12.2, which is the version that Houdini 20.5 is using:
$ find /opt/hfs20.5.445/toolkit/include -path '*/QtWidgets/*' -type d
/opt/hfs20.5.445/toolkit/include/QtWidgets/5.15.2
/opt/hfs20.5.445/toolkit/include/QtWidgets/5.15.2/QtWidgets
/opt/hfs20.5.445/toolkit/include/QtWidgets/5.15.2/QtWidgets/private
Let's enter the image real quick and confirm we have qmake
:
$ docker run -ti aswf/ci-baseqt:2022.2 bash
[root@0b2f6800ba3e aswf]# qmake --version
QMake version 3.1
Using Qt version 5.15.2 in /usr/local/lib
Yay! One less thing to worry about.
So now let's prepare the Dockerfile. I've shared here the final version, and you can find all of the other related scripts (including the ./hackit.sh
oneliner that runs the complete spiel) at the bottom of the article.
We'll also need a few header files from Qt, which I'm gonna bring in via a simple rsync -avP /opt/hfs20.5.445/toolkit/include .
.
Here's the Dockerfile
, responsible of the compilation:
FROM aswf/ci-baseqt:2022.2 as build-stage
WORKDIR /opt/hfs20.5.445/toolkit
COPY include .
WORKDIR /opt/splash-screen
COPY splash.cpp .
COPY splash.pro .
RUN qmake -o Makefile splash.pro && make
FROM scratch AS export-stage
COPY --from=build-stage /opt/splash-screen/ .
And here's an extract of the build commands, leveraging the multi-stage build:
sudo docker build \
--file Dockerfile \
--tag vv-splash-screen \
--target build-stage .
mkdir -p artifacts
DOCKER_BUILDKIT=1 sudo docker build \
--file Dockerfile \
--tag vv-splash-screen \
--target export-stage \
--output=artifacts .
sudo chown "$(whoami)" -R artifacts
sudo chmod 755 -R artifacts
As for the actual juice (the splash.cpp
file that does the method override!),
I've posted my own version at the end of the article too, but the code is very similar to the one in https://github.com/heavyimage/OverrideQtSplashscreen/blob/master/splish_splash.cpp, since the same ideas still hold:
- Define your own implementation of
QSplashScreen::setPixmap
, using the exact same signature - Grab the original implementation via
dlsym()
, so we can run it after running own custom code to change the pixmap - Compile that file as a shared library, and preload it before the official one so that it overrides it
If you remember, we took a note of the mangled named of QSplashScreen::setPixmap()
. Now it's the time to use it!
We'll feed it into the dlsym()
call so that we retrieve the current implementation, basically this:
typedef void (*orig_fn_type)(QSplashScreen *, const QPixmap &pixmap);
orig_fn_type orig_setpixmap = (orig_fn_type)dlsym(RTLD_NEXT, "_ZN13QSplashScreen9setPixmapERK7QPixmap");
orig_setpixmap(this, hacked_pixmap);
If everything went well, at the end of Docker build we should see these files on the host machine:
$ tree artifacts/
artifacts/
├── libinject_splash.so -> libinject_splash.so.1.0.0
├── libinject_splash.so.1 -> libinject_splash.so.1.0.0
├── libinject_splash.so.1.0 -> libinject_splash.so.1.0.0
├── libinject_splash.so.1.0.0
├── Makefile
├── splash.cpp
└── splash.pro
0 directories, 7 files
The only problem is that inside the container we'll link against a particular installation of Qt in /usr/local/lib
, which is not the same version that Houdini links to on our local machine:
[root@d1d3e564d2bb splash-screen]# ldd libinject_splash.so | grep Qt
libQt6Widgets.so.6 => /usr/local/lib/libQt6Widgets.so.6 (0x00007042f62e2000)
libQt6Gui.so.6 => /usr/local/lib/libQt6Gui.so.6 (0x00007042f5a3f000)
libQt6Core.so.6 => /usr/local/lib/libQt6Core.so.6 (0x00007042f544f000)
libQt6DBus.so.6 => /usr/local/lib/libQt6DBus.so.6 (0x00007042f6ffe000)
Worry not - we can use patchelf
to tweak those .so
files and let them look into the right place, which we discovered to be /opt/hfs20.5.445/dsolib/libQt5Widgets.so.5.15.2
(for example). We'll need to patch this from outside the container though, since patchelf
needs to be able to access libQt5Widgets.so.5.12.2
on the host filesystem.
One quick way is to just add a RUNPATH entry to nudge our library to look in the right place:
$ patchelf --add-rpath \
/opt/hfs20.5.445/dsolib \
artifacts/libinject_splash.so
# Just triple checking:
$ objdump -x artifacts/libinject_splash.so | grep RUNPATH
RUNPATH /opt/hfs20.5.445/dsolib
After the patching is done, this is what we'll see resolved instead:
$ ldd artifacts/libinject_splash.so | grep Qt
libQt5Widgets.so.5 => /opt/hfs20.5.445/dsolib/libQt5Widgets.so.5 (0x0000735276400000)
libQt5Gui.so.5 => /opt/hfs20.5.445/dsolib/libQt5Gui.so.5 (0x0000735275c00000)
libQt5Core.so.5 => /opt/hfs20.5.445/dsolib/libQt5Core.so.5 (0x0000735275600000)
Amazing! So now everything is ready.
The libinject_splash.so
is compiled and linked successfully, so what we need is just to run Houdini and tell it to run load libinject_splash.so
before everything else:
LD_PRELOAD="$(pwd)/artifacts/libinject_splash.so" houdini -foreground
And ta dan! IT STILL WORKS in 2025!!
Wrapping everything up, here's my hackit.sh
script:
#!/bin/bash
set -e
copy_include(){
rsync -avP /opt/hfs20.5.445/toolkit/include .
}
build(){
sudo docker build \
--file Dockerfile \
--tag vv-splash-screen \
--target build-stage .
mkdir -p artifacts
DOCKER_BUILDKIT=1 sudo docker build \
--file Dockerfile \
--tag vv-splash-screen \
--target export-stage \
--output=artifacts .
sudo chown "$(whoami)" -R artifacts
sudo chmod 755 -R artifacts
}
patch_libs(){
patchelf --add-rpath \
/opt/hfs20.5.445/dsolib \
artifacts/libinject_splash.so
}
run(){
pushd /opt/hfs20.5.445
. houdini_setup || echo "houdini was not sourced!"
popd
target_image="$(pwd)/your-own-splash-screen.png"
export OVERRIDE_QT_SPLASH="$target_image"
LD_PRELOAD="$(pwd)/artifacts/libinject_splash.so" houdini -foreground
}
copy_include
build
patch_libs
run
Here's the splash.pro
script, which is what qmake
needs to generate the Makefile:
CONFIG += debug
TARGET = inject_splash
TEMPLATE = lib
DEPENDPATH += .
INCLUDEPATH = .
INCLUDEPATH = /opt/hfs20.5.445/toolkit/include
QT += widgets
QT += gui
QT += core
SOURCES += splash.cpp
Here's the splash.cpp
:
// All credits go to the original author (heavyimage) :
// https://github.com/heavyimage/OverrideQtSplashscreen/blob/master/splish_splash.cpp
// I only modified a few bits for my own understanding
#include <stdio.h>
#include <stdlib.h>
#include <QSplashScreen>
#define _GNU_SOURCE
#include <dlfcn.h>
const char *FUNC_SYMBOL = "_ZN13QSplashScreen9setPixmapERK7QPixmap";
const char *ENV_VAR_NAME = "OVERRIDE_QT_SPLASH";
typedef void (*orig_fn_type)(QSplashScreen *, const QPixmap &pixmap);
void QSplashScreen::setPixmap(const QPixmap &pixmap) {
const char *img_path = std::getenv(ENV_VAR_NAME);
// Fall back on the original if our image can't be loaded
QPixmap hacked_pixmap;
if (!hacked_pixmap.load(img_path)) {
hacked_pixmap = pixmap;
}
printf("--> hacked splash: %s\n", img_path);
printf("--> original pixmap: %p\n", pixmap);
printf("--> hacked pixmap: %p\n", hacked_pixmap);
// from `man dlsym`:
// "obtain address of a symbol in a shared object or executable"
// In unusual cases (see NOTES) the value of the symbol could actually be
// NULL. Therefore, a NULL return from dlsym() need not indicate an error.
// The correct way to distinguish an error from a symbol whose value is NULL
// is to call dlerror(3) to clear any old error conditions, then call dlsym(),
// and then call dlerror(3) again, saving its return value into a variable,
// and check whether this saved value is not NULL.
dlerror();
orig_fn_type orig_setpixmap = (orig_fn_type)dlsym(RTLD_NEXT, FUNC_SYMBOL);
char *result = dlerror();
if (orig_setpixmap == NULL && result != NULL) {
printf("--> Error while getting address of %s: %s\n", FUNC_SYMBOL,
dlerror());
} else {
orig_setpixmap(this, hacked_pixmap);
}
}
Have fun!
Post Scriptum
After chatting with them, heavyimage
was kind enough to remind me that there is actually a dedicated HOUDINI_SPLASH_FILE
env variable that you can use to tweak the splash screen of Houdini (see https://www.sidefx.com/docs/houdini/ref/env.html), but you know the drill:
The real cycle you're working on is a cycle called yourself.
- Robert M. Pirsig
aka
The real DCC you're hacking on is a DCC called yourself.