libserum is a cross-platform library for decoding Serum colorization files for
pinball ROMs.
Originally, libserum was created by
Zed. The last upstream/original release
line was v2.3.1. This repository is the actively maintained successor fork.
libserum loads a colorization package from the altcolor/<romname>/
directory, identifies incoming ROM frames, colorizes them, applies color
rotations, handles sprites and dynamic zones, and optionally plays rotation scene
animations.
At runtime, the caller mainly interacts with:
Serum_Load(...)Serum_Colorize(...)Serum_Dispose()
All runtime output is returned through Serum_Frame_Struc, defined in
src/serum.h.
libserum supports two Serum content generations:
Serum v1- classic palette-based colorization output
Serum v2- direct color frame output for 32-pixel and 64-pixel height DMD planes
- optional rotation scenes
This fork also supports the concentrated archive format:
*.cROMc- preprocessed runtime archive generated from
cROM/cRZsource data - supports both
Serum v1andSerum v2content - stores additional derived runtime-ready data so startup is faster and RAM use is lower than rebuilding everything from raw source data every time
- Memory paeks can happen when loading the colorization
- preprocessed runtime archive generated from
Compared to the original upstream line ending at v2.3.1, this fork adds and
maintains:
cROMcsupport- concentrated runtime archive format
- automatic generation from raw
cROM/cRZsource files - much better startup behavior on repeated loads
- better cross-platform support
- Windows, macOS, Linux, iOS, tvOS, Android
- Serum v2 rotation scene support with persisted runtime lookup data
- monochrome fallback handling for non-colorized ROM frames
- extensive bug fixes and performance work in:
- frame identification
- sprite handling
- dynamic zones
- scene playback
- direct
cROMcruntime loading
The important architectural difference is:
- original
libserum v2.3.1primarily worked with raw Serum source files - this fork keeps raw source support, but treats
cROMcas the preferred runtime format
On desktop/authoring-style usage, libserum typically:
- looks for
*.cROMc - if not present or skipped, loads raw
*.cROM/*.cRZ - for
Serum v2, optionally applies*.pup.csv(only relevant for colorization authors) - can write an updated
*.cROMc
On real-machine targets:
- only
*.cROMcis supported *.pup.csvandskip-cromc.txtare ignored
libserum supports two dedicated trigger IDs:
-
65432(MONOCHROME_TRIGGER_ID)- enables monochrome fallback mode
- in
v2, incoming ROM shades are rendered with fixedgreyscale_4/greyscale_16 - in
v1, the configured standard monochrome palette is used
-
65431(MONOCHROME_PALETTE_TRIGGER_ID)- enables palette-based monochrome fallback mode (
v2only) - the monochrome palette is captured from dynamic color set
0of the triggering frame - subsequent monochrome frames use this captured palette instead of the fixed greyscale palette
- enables palette-based monochrome fallback mode (
Both modes remain active while frames are unknown/not colorized and are reevaluated when a new frame is identified.
If you ask libserum for 256x64 frames and the ROM is 128x32, you get
256x64 frames. libserum performs the upscale itself, using the algorithm the
colorization author selected, rather than leaving it to the host. That is the
only way a colorization looks the same in every player, so hosts should no
longer scale Serum output themselves.
A frame is rendered as two layers:
- HD-authored content — static colorization, background images and background
scenes — is rendered natively at
256x64and stays sharp. - Everything driven by the
128x32ROM frame — dynamic zones, sprites without an HD version, and colour rotations — is rendered at original resolution and upscaled once, then composited on top.
Dynamic shadows are generated afterwards, directly on the upscaled result, so a shadow always follows the shape of the glyph it belongs to.
Two algorithms are available:
- Scale2x preserve (default) — Scale2x that never drops a pixel the source
lit. Reference Scale2x rounds convex corners by taking a neighbour, and where
that neighbour is empty the pixel is simply lost — which eats DMD text, often
only five pixels tall, to the point where
S,RandCstop being readable. This keeps the centre in that one case, so the result is the union of Scale2x and line doubling. On artwork it changes well under 1% of pixels and is invisible. - Scale2x (AdvMAME2x) — the reference algorithm, unmodified
- line doubling — each source pixel becomes a
2x2block
Scale2x preserve is the default because a colorization has no way to ask for
one algorithm on its text frames and another on its artwork — most ROMs
alternate between the two constantly — and this one is right for both. The algorithms come from
libframeutil, shared with the rest of
the PPUC stack, and hosts can read the selection back with
Serum_GetScalingAlgorithm() so any further scaling they do matches.
Authors override the defaults with an optional sidecar next to the colorization files:
altcolor/<romname>/scaling.txt
Each non-empty line is either a bare algorithm name or a key: value setting.
# starts a comment:
# the reference algorithm, if you want exactly what other players produce
scale2x
shadow-offset: proportional
| setting | values | default | meaning |
|---|---|---|---|
(bare word) or scaling: |
scale2x-preserve, scale2x, line-doubling |
scale2x-preserve |
upscaling algorithm |
shadow-offset: |
native, proportional |
native |
how far dynamic shadows are offset on the upscaled plane |
shadow-offset: native offsets a shadow by one 256x64 pixel, which is what
libserum has always rendered into the high-resolution plane and therefore what
most colorizations were tuned against. proportional uses two pixels, keeping
the shadow's thickness proportional to the glyph and matching the 128x32
output. Tight glyphs such as 8 can lose the gap between their loops under
proportional, while thicker fonts often look better with it — which is why it
is a per-colorization choice.
Dropping in or editing scaling.txt next to an existing *.cROMc regenerates
that *.cROMc on the next load, so the settings take effect immediately and are
then carried by the archive itself. On real-machine targets scaling.txt is not
read and the values stored in the *.cROMc are used.
Downscaling is not done by libserum. A 128x32 request against 64p-only
content still returns the 64p frame, and the host decides how to reduce it.
For Serum v2, scenes are authored in *.pup.csv.
Format of a PUP scene line:
1: PUP scene ID
2: number of frames
3: duration of each frame
4: 0 - not interruptable, 1 - interruptable by frame match or PUP event
5: 0 - start immediately, replacing the triggering frame, 1 - start after frame duration
background scenes do not replace the triggering frame; instead the first
background scene frame is prepared immediately and the triggering normal
frame still renders in the foreground
6: 0 - play once, 1 - loop, >= 2 - repeat x times
7: 0 - no frame groups, >= 2 - create x frame groups
8: 0 - play frame group in order, 1 - play random frame group
9: 0 - no autostart, >= 1 - start this scene after x seconds of inactivity
if scene flag 0 is used for a non-interruptable scene, this value is used as
end-hold duration in seconds instead
10: scene flags
the finish behavior is selected by the low bits: 0, 1 or 2
0 - default: keep the last scene frame visible when the scene finishes
until a new normal frame is identified
if that next identified normal frame would immediately retrigger the
same scene, the scene is not restarted and the preserved last scene
frame remains visible
1 - black screen when scene finished
2 - show last frame before scene started when scene finished
4 - run scene as background
after a background scene finishes with flag 0, its last scene frame
remains visible in the background until a newly identified normal frame
stops that background state; same-trigger continuation does not clear it
8 - replace static content with background scene, only dynamic zones,
sprites and shadows stay in the foreground
16 - continue scene at previous frame when interrupted for less than 8s
32 - with flag 4, replace dynamic-zone pixels whose selected dynamic color is
black with the background scene; these pixels do not generate dynamic
shadows and do not replace already-written dynamic shadow pixels
Positions 4 to 10 are optional. If not provided, the default is 0.
cmake -G "Visual Studio 18 2026" -DPLATFORM=win -DARCH=x64 -B build
cmake --build build --config Releasecmake -G "Visual Studio 18 2026" -A Win32 -DPLATFORM=win -DARCH=x86 -B build
cmake --build build --config ReleaseRequires MSYS2 with UCRT64 environment. Install dependencies:
pacman -S --noconfirm \
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-libwinpthread \
mingw-w64-ucrt-x86_64-cmakeBuild inside the MSYS2 UCRT64 shell:
MSYSTEM=UCRT64 /c/msys64/usr/bin/bash.exe -l -c "
cd \"$(pwd)\" &&
cmake -DCMAKE_BUILD_TYPE=Release -DPLATFORM=win-mingw -DARCH=x64 -B build &&
cmake --build build -- -j\$(nproc)
"cmake -DPLATFORM=linux -DARCH=x64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=linux -DARCH=aarch64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=macos -DARCH=arm64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=macos -DARCH=x64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=ios -DARCH=arm64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=ios-simulator -DARCH=arm64 -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=tvos -DARCH=arm64 -DBUILD_SHARED=OFF -DCMAKE_BUILD_TYPE=Release -B build
cmake --build buildcmake -DPLATFORM=android -DARCH=arm64-v8a -DCMAKE_BUILD_TYPE=Release -B build
cmake --build build