Skip to content
Always Shippable

Sierra Games · Part 1 of 3

Upscaling Sierra SCI0 PIC: the native render oracle

How snap-blend upscales Sierra SCI0 vector backgrounds without leaking flood fills, using a native-resolution render as the oracle for every scaled fill.

Updated 12 min read
A sealed pixel diamond beside a 3x copy where a one-pixel gap lets the fill spill past the line
On this page

Sierra On-Line’s SCI0 engine draws backgrounds as vector commands: lines, patterns, and flood fills executed in sequence at 320x190. It works perfectly at native resolution. But render those same commands at 2x, 3x, or higher? Fills leak through hairline gaps that didn’t exist at 320 pixels wide.

This is the problem snap-blend solves.

The problem#

SCI0 PICs aren’t rasterized images; they’re programs. A PIC resource is a stream of drawing commands: draw a line here, fill this region there. The fill commands depend on boundaries being watertight. At native resolution, the integer-coordinate Bresenham lines form perfect barriers.

A PIC editor listing Line commands beside the crashed-spaceship wireframe those lines draw

Scale everything up and suddenly there are fractional-pixel gaps where fill ink escapes into neighboring regions, corrupting the image. Scaling can also fragment boundaries, causing fills to terminate early or leave holes.

A simplified PIC command stream looks something like this:

SET_VISUAL_COLOR 0x03        // dark cyan
// Diamond
DRAW_LINE (160, 40) → (240, 95)
DRAW_LINE (240, 95) → (160, 150)
DRAW_LINE (160, 150) → (80, 95)
DRAW_LINE (80, 95) → (160, 40)
// Line intended to bisect the Diamond
DRAW_LINE (120, 68) → (200, 123)
FILL (160, 95)               // flood fill stays bounded

At 320x190, the rasterized lines happen to touch cleanly, so the shape remains sealed and the fill stays inside.

Scale the same geometry to 960x570, however, and the independently rounded midpoint coordinates may no longer land exactly on the scaled diagonal edges. That can create a one-pixel gap at the junction, allowing the flood fill to escape and flood the entire canvas.

Two cyan diamonds: the native fill stays sealed, while at 3x a marked one-pixel gap lets it leak

Existing solutions don’t help. ScummVM and SCI Companion render at native res. Pixel-art upscalers (Scale2x, hqx, xBR) operate on finished rasters; they can’t fix a fill that leaked during rendering. Upscaling after rasterization preserves correctness, but introduces uneven line thickness and edge wobble. The problem has to be solved inside the renderer.

Sweep of a Space Quest 3 hangar room with a crashed ship: Scale6x gives wobbly uneven outlines
Original 320x190 render versus a Scale6x post-raster upscale of the same Space Quest 3 room; the sweep moves back and forth between the two.

Snap-blend fixes this by altering the rendering directly.

Sweep of the same hangar room: Scale6x wobbly outlines versus snap-blend's clean one-pixel lines
Scale6x versus snap-blend on the same room: straight, evenly weighted lines and the same regions.

The approach#

snap-blend combines several techniques into a pipeline: a native-resolution pass first, then a scaled pass that the native result supervises fill by fill.

The snap-blend rendering pipelineThe PIC command stream is rendered once at native resolution to record how many pixels each FILL touches. The same commands are then rendered at the target scale: lines get snapped endpoints, and before each fill a micro-gap pass closes diagonal gaps. Each fill runs under a guard: if it exceeds five times its native count times scale squared, or forty percent of the screen, it is rolled back mid-fill. A fill that ends with zero pixels while its native count is non-zero is recovered by blitting the nearest-neighbour scaled native mask; undershoots pass through untouched. A final dither-to-RGBA blend runs over the whole canvas.

1. Endpoint snapping and seed nudging#

Line endpoints snap to nearby already-drawn boundary pixels: a scan around the scaled endpoint, under a small distance budget and a directional cone, finds an existing pixel to land on, so scaled lines actually touch the boundaries they were drawn to meet.

function snapEndpoint(x, y):
    // scan a small radius around the scaled endpoint for an
    // already-drawn pixel, within a distance budget and a
    // directional cone; snap to the closest hit, else keep (x, y)

Fill seeds get the mirror treatment. At scale, boundary lines are thick enough to cover a seed’s coordinates, and a fill seeded on a boundary paints zero pixels, the single biggest failure class the oracle exposed. Seeds are nudged off boundaries before the fill runs.

2. Diagonal micro-gap closure#

A morphological pass detects and closes single-pixel diagonal gaps that form at scaled line junctions. These are the most common leak source.

// scan for diagonal gaps in the boundary mask:
//   . X      X .
//   X .  or  . X
// if found, fill one pixel to close the gap
for each pixel (x, y) in boundaryMask:
    if isEmpty(x, y) AND isBoundary(x-1, y-1) AND isBoundary(x+1, y+1):
        boundaryMask[x, y] = FILLED
    if isEmpty(x, y) AND isBoundary(x+1, y-1) AND isBoundary(x-1, y+1):
        boundaryMask[x, y] = FILLED

The pass is a two-pixel test: an empty cell whose diagonal neighbours are both boundary gets filled, which is enough to seal the junction.

Diagonal micro-gap closure on a 6 by 6 pixel gridTwo grids with a diagonal boundary that has one missing pixel. In the left grid the flood fill has spread through the gap to both sides of the diagonal; in the right grid the gap pixel is filled and the fill covers only the region above the diagonal.
One diagonal micro-gap before and after closure. Left, a flood fill leaks through the empty cell between two diagonal boundary pixels; right, that one cell is filled and the fill stays on its side.

3. Native render oracle#

The renderer first builds a native-resolution reference render and uses it to validate each scaled flood fill. Instead of predicting where scaling errors might occur, snap-blend renders once at native resolution and uses that output as ground truth.

// Phase 1: render at native res to build the oracle
nativeCanvas = render(picCommands, scale=1)
for each FILL command[i]:
    expected[i] = countPixelsFilled(nativeCanvas, command[i])

// Phase 2: render at target scale, supervised by the oracle
scaledCanvas = createCanvas(320 * scale, 190 * scale)
for each command in picCommands:
    if command is DRAW_LINE:
        drawSnappedLine(scaledCanvas, command, scale)
    if command is FILL:
        closeGaps(scaledCanvas)  // micro-gap closure pass
        limit = expected[i] * scale * scale * MAX_OVERSHOOT  // 5.0
        actual = guardedFloodFill(scaledCanvas, command, limit)
        // the guard aborts and unwinds the fill mid-flood if it
        // passes the limit, or 40% of the whole screen

        if actual == 0 AND expected[i] > 0:
            // rolled back, or the seed never produced a pixel
            blitNativeMask(scaledCanvas, nativeCanvas, command, scale)
        // undershoots are kept as-is

If a fill blows far past its native-predicted area, it’s a leak; the guard unwinds it mid-flood rather than letting it flood the canvas. The native render acts as an oracle supervising the upscaled render. Undershoots are tolerated: thicker upscaled lines eat fill area by construction, so a somewhat-small fill is expected physics at scale, not a leak.

4. Native-mask fallback#

When a fill ends with zero pixels (rolled back by the guard, or its seed never produced one) while the native render says the region should have some, the corresponding region from the native render is nearest-neighbor-scaled and composited in. Correctness is preserved at the cost of sharpness in that region, but in practice, fallback fires rarely.

function blitNativeMask(scaledCanvas, nativeCanvas, fillCmd, scale):
    // find the filled region in the native render
    mask = extractFillRegion(nativeCanvas, fillCmd)
    // nearest-neighbor upscale the mask
    upscaled = nearestNeighborScale(mask, scale)
    // composite into the scaled canvas
    composite(scaledCanvas, upscaled, fillCmd.color)

5. Dither-to-RGBA blending#

A cosmetic post-process. SCI0 uses dithered patterns to simulate colors beyond the 16-color EGA palette. The blend pass resolves these into smooth RGBA values for the browser renderer.

// SCI0 stores each pixel's dither pair in its byte: (high << 4) | low
// e.g. 0x3b = cyan (0x3) + light cyan (0xb), drawn as an
// alternating checkerboard at native resolution
//
//   C L C L        blended:
//   L C L C   →    every 0x3b pixel becomes the same
//   C L C L        perceptual mix of the pair
//   L C L C

// No pattern detection needed; the byte itself names both colours.
for each pixel byte in output:
    rgba = BLEND_TABLE[byte]   // precomputed okLab mix of the pair
Dither checkerboard blended to a single RGBA colourLeft, a 4 by 4 pixel checkerboard alternating two colours, with one 2 by 2 block outlined; right, a solid square of the perceptual mix of the two colours. The output keeps the same footprint and loses only the checkerboard.
The blend pass in one block: a checkerboard of two EGA palette entries becomes pixels of their perceptual mix. Region shapes are untouched.

This produces output that looks cleaner on modern displays without altering the underlying data.

What’s novel#

None of the individual techniques are new. The interesting part is combining them into a supervised rendering pipeline where the native render validates the scaled one.

I haven’t seen this approach used in retro vector renderers before. The problem domain is niche, but the pattern generalizes. Any format where vector drawing commands produce resolution-dependent fill boundaries could benefit: AGI-era Sierra games, LucasArts SCUMM backgrounds, or other retro engines with interleaved draw/fill command streams.

Results#

Across the test corpus at 6x, snap-blend produced no catastrophic fill floods: escapes are caught mid-fill, rolled back, and recovered from the native mask. The flagged fills that remain are overwhelmingly small boundary-thickness undershoots, plus a handful of guarded micro-gap escapes the rollback contains. The blend pass is purely cosmetic: same correctness, nicer pixels.

It’s the production strategy (registered as snap-v5-blend) for my sci.js project, a TypeScript reimplementation of the SCI0 engine focused on making Space Quest 3 playable in the browser with upscaled visuals, built on 32bitkid’s @4bitlabs decoding libraries.

Each comparison below sweeps between the two renders of one Space Quest 3 room.

Sweep of an alien landscape with a crashed ship: native render versus snap-blend at 6x
Native 320x190 render versus snap-blend at 6x: identical regions, cleaner lines.
Sweep of a scrap heap with a magenta hull section, a cyan arch and orange rock behind
Original versus snap-blend: junk pile with a magenta hull section and cyan arch.
Sweep of three cyan-ringed tunnel mouths in a red and blue cavern with a small ship in the corner
Original versus snap-blend: sewer tunnels under a blue-lit ceiling.
Sweep of a huge magenta and grey robot head with a green gem, hull pieces piled on the left
Original versus snap-blend: giant robot head among the wreckage.
Sweep of a green and cyan ship suspended in a blue-lit hangar with rubble on the floor
Original versus snap-blend: ship in a blue hangar bay.
Sweep of a scrapyard littered with blue debris, a wrecked white pod, and yellow tubes and pipes
Original versus snap-blend: scrapyard with a wrecked pod and yellow tubes.
Sweep of a grey and magenta spaceship cockpit with a round viewport and a pilot chair
Original versus snap-blend: ship cockpit interior.
Sweep of an arcade screen reading Astro Chicken by ScumSoft with a cartoon chicken
Original versus snap-blend: the Astro Chicken arcade cabinet.
Sweep of a space fast-food interior with a curved counter, magenta floor and stars outside
Original versus snap-blend: inside Monolith Burger.
Sweep of the round brown Monolith Burger station with a red sign and docking arm against stars
Original versus snap-blend: Monolith Burger from outside.
Sweep of rolling magenta dunes with green and blue rock outcrops under a dark striped sky
Original versus snap-blend: magenta desert dunes.

Upscaled animation overlays#

One practical extension is compositing higher-resolution animated elements on top of snap-blend backgrounds. Because snap-blend produces clean, stable regions, it makes a reliable base layer; animated sprites, whether traditionally upscaled or AI-enhanced, composite on top as a separate overlay. In motion the eye forgives minor inconsistencies in sprite detail but not structural errors in the background, so as long as the underlying regions are correct the result reads as coherent. Keeping the background deterministic also sidesteps the hardest problem in AI upscaling, frame-to-frame consistency: enhancement is limited to the overlay, so temporal artifacts never compound.

Deterministic base with an enhanced overlay layerThe snap-blend background and the priority and control masks come from the deterministic renderer and are composited exactly. Animated sprites, upscaled or AI-enhanced, are composited on top as a separate overlay layer, so enhancement never changes the regions the game logic depends on.

Eight-frame walk cycle of Space Quest's Roger Wilco as native pixel art on a blue background

The same walk cycle redrawn at higher resolution with smoother outlines and no background

The future#

Once the renderer became structurally reliable, the next question was how far visual enhancement could go without breaking correctness.

AI upscaling looks like an obvious shortcut. Take the original 320x190 output, run it through a modern image model, and get something that looks like a painted remake.

Sweep of the crashed-ship hangar: EGA render versus a photorealistic AI upscale with a metal hull
Original render versus an AI-upscaled painting of the same hangar room.

That works for screenshots. It breaks down for a renderer.

SCI0 backgrounds are not just images, they are structured scenes with implicit gameplay constraints. Walkable areas, object boundaries, and interaction zones all depend on the exact shape of regions. Because generative models can shift those boundaries unpredictably, AI is better treated as a constrained enhancement layer on top of a deterministic renderer.

The priority map, below: different colors tell the engine if the avatar should be seen in front or behind them.

The hangar priority map: flat cyan, blue, magenta and red regions on black, one per depth band

The control map, below: white areas block the avatar. Black areas are walkable with no trigger. Any other colour is walkable and triggers an event when walked over.

The same room's control map: mostly white, with black walkable bands and green event-trigger patches

The road here#

This was strategy v5 out of ten attempts. Strategies v1 through v4 each solved part of the problem (better line expansion, smarter gap detection) but none handled the full space of edge cases. The native render oracle was the breakthrough: instead of trying to predict where gaps would form, just render at native res first and let the result tell you what’s correct. v6 through v10 explored alternatives; v10 even improved the metric slightly, but not visibly enough to earn a switch; that detour has its own write-up. And one successor isn’t a snap variant at all: the anchor-graph upscaler, a separate paradigm that has since won the PIC visual path. snap-v5-blend remains the game-runtime strategy, because the anchor-graph renderer doesn’t produce the priority and control layers the engine needs.

Sometimes the best algorithm is “do it twice (or ten times) and compare.”

An AI-generated scene: two red-mohawked alien guards in sunglasses facing a mustached man in a suit