Sierra Games · Part 2 of 3
Trying agi-up as a reference renderer for SCI0
Borrowing from a native-resolution AGI upscaler for a 6× SCI0 pipeline: one geometric rule transferred, the architecture did not, and why.

On this page
I’ve been working on a TypeScript reimplementation of the Sierra SCI0 engine. One piece that keeps giving me trouble is upscaling the 320×190 PIC backgrounds to a modern resolution (1920×1140, 6× scale) without flood-fill leaks. I have a lineage of rendering strategies, snap through snap-v8, that chip away at the problem from different angles, and snap-v5 in its blend variant (snap-v5-blend) is still the one I ship for game rendering. The native render oracle post covers how they are measured.
Recently I came across OldManYellsAtCode1’s agi-up project (with a walkthrough on YouTube), a beautifully structured C implementation of an upscaler for Sierra’s AGI engine, the predecessor to SCI0. Different approach entirely from mine: it uses an “anchor graph” built from per-pixel command provenance and re-rasterizes the boundaries from that graph. I wanted to see if it could help me past the ceiling my pixel-space tactics had hit. This post is a retrospective on that experiment.
A couple of notes up front. AGI and SCI0 are related Sierra engines, but their PIC formats and opcode layouts differ substantially. This isn’t a port between two renderers of the same format, it’s two renderers solving the broader problem of “upscale a vector PIC without artifacts” under different constraints.
The agi-up code is good engineering for the problem it solves; the problem it solves just isn’t quite the one I have. I wanted to write down why, both for myself and because I’d love to find places we can collaborate.
The setup#
Before experimenting with the agi-up ideas, here’s the state of my existing pipeline. My snap-v* lineage solves the upscale problem by:
- Block expansion: each native line pixel becomes a 6×6 block at 6× scale.
- Endpoint snapping and seed nudging (v1)
- Adaptive per-fill leak guards with rollback (v2/v3)
- Native-mask fallback when an upscaled fill produces zero pixels where the native render has some (v4)
- Diagonal pixel-pattern gap closure before each fill (v5)
The block expansion constraint turned out to matter more than expected. My v6 experiment showed that lines thinner than the scale factor catastrophically leak (92 → 547 flagged fills; note the v6 experiment was measured on a different run of the corpus than the lineage numbers below, so the baselines differ) because Bresenham stepping creates gaps the flood fill can escape through.
These form a chain of additive defenses, validated by a metric I call “flagged fills”: how many fill commands in a PIC produce a pixel count more than 25% off from the native render, with a 75-pixel absolute floor so small deviations never flag. Across 11 representative PICs, v5 reduced flagged fills from 534 to 193, a 64% improvement. But v5’s own docstring is blunt about the wall: 92% of the remaining flagged fills are undershoots from thick upscaled lines stealing fill area. Gap closure cannot fix that.
What drew me to the agi-up code#
The reference does something my repo doesn’t: it tracks which draw command rasterized each pixel (ref_cmd_buf) and what kind of command it was (line vs fill, via cmd_type_buf). It uses that to build an anchor graph (each cell’s anchor is projected onto the nearest line segment of its owning command) and then connects anchors with geometrically reasoned rules, like “don’t connect a diagonal if both flanking cardinals are line cells.” It’s a coherent model. The anchor graph and the enhancement passes both express ideas I hadn’t seen elsewhere.
What I hoped I could borrow:
- Per-pixel command provenance
- Structural geometric gap detection
- Anchor-graph boundary rerasterization
I built two new strategies: snap-v9 (v4 + the structural rule applied as a barrier seal) and snap-v10 (v5 + the same seal, stacked).
Here is where the borrowed piece slots into the existing chain: a single extra pass, fed by the two new provenance buffers, that runs at the start of the per-fill work, before gap closure and the flood fill.
What the numbers said#
On a 5-PIC sample (PICs 2–6), V10 improved or tied v5 on every PIC (no per-PIC regressions), reducing total flagged fills by 10.9%:
| Strategy | Leaks | Undershoots | Total flagged | Δ vs v5 |
|---|---|---|---|---|
| v5 (baseline) | 6 | 40 | 46 | n/a |
| v9 (v4 + seal) | 8 | 42 | 50 | +8.7% |
| v10 (v5 + seal) | 5 | 36 | 41 | −10.9% |
I rendered the V10 output as a PNG and put it next to V5. At normal viewing distance, the visual difference is effectively imperceptible. The 5 “fixed” fills out of ~650 in the sample are localized boundary patches, fractions of a percent of total pixel area.
What helped, and why#
One specific thing transferred cleanly: the diagonal-flanking suppression rule.
In the agi-up code, this rule lives inside the anchor-graph logic and decides whether to draw a connection between adjacent anchors. I lifted it out and used it as a pre-fill barrier seal, tightened in the port to fire only on true X-junctions: “for each native fill-fill diagonal flanked by two different line commands, plant a single pixel at the upscaled junction corner before the flood fill runs.” The total change was only a few dozen pixels per PIC.
That one rule produced the metric improvement. The mechanism is leak-shaped: it seals the Bresenham staircase gaps at line junctions so flood fills stay contained and nudged seeds can’t wander into the wrong region. Most of the flags it cleared, though, showed up in the undershoot column (40 → 36 against v5) rather than the leak column (6 → 5).
I could have arrived at the same observation from my own v5 docstring (which notes that diagonal gaps form “at line junction points where Bresenham lines meet at angles”), but the agi-up code crystallized the geometric shape of the condition, a fill–fill diagonal flanked by line cells, tested at native resolution, in a way I would not have gotten to on my own. (The further tightening to two different line commands, true X-junctions rather than L-bends inside one polyline, happened in my port.) That’s a real contribution to my project, even if its impact ended up being small.
What didn’t transfer, and why#
Most of the agi-up code didn’t fit my pipeline. To be precise about why: it’s not that the ideas are wrong or weaker than mine. Many of the agi-up code’s best ideas depend on assumptions that don’t hold in my pipeline.
The anchor projection (projecting each fill cell’s anchor onto its owning line segment) produces lovely junction geometry. In a native-resolution output it gives you a single coherent visual model. My pipeline draws lines at upscaled resolution directly from the source command, so I don’t have a step where projected anchor positions would attach. The projection is doing real work in the agi-up renderer; there’s just no slot for it in mine.
The anchor-to-anchor re-rasterization (drawing thin Bresenham lines between connected anchors) is the structural heart of the agi-up approach, and it’s elegant. At native resolution, where 1-pixel-wide lines are the right answer, this is the correct design. In my 6× upscale pipeline, my v6 experiment had already shown that thin lines collapse fill containment. So the elegance of the agi-up approach is bought against an assumption (native-resolution output) that my pipeline doesn’t share.
The enhancement passes (the enhance() kernel) apply neighborhood voting to fill in flat-region gaps. I didn’t try porting them, partly because my v7 experiment already documented that any pass adding pixels between fill commands inflates subsequent fill counts and confuses the per-fill leak metric. That’s a constraint specific to how my pipeline is instrumented. The kernel itself is solid; it just collides with my measurement model.
The command-provenance data structure is genuinely new infrastructure for my pipeline. On its own it’s just two buffers; the value is in what you build with it. The diagonal-flanking rule is the one thing in this experiment I built with it that produced any metric movement. There may be more I could do with the same buffers later (smarter seed nudging is one idea I have for the future), and if so, the work of standing up that infrastructure won’t have been wasted.
What I wish I’d done differently#
I should have trusted my v5 conclusions earlier. The remaining defects were already telling me the same thing: thick upscaled lines structurally steal fill area, and no further pixel-space tactic was going to solve that.
The real next step, independent of the agi-up reference, is a structural change to the pipeline itself: a dual-layer renderer where the visual layer uses thin lines while the control/barrier layer stays thick, letting containment and fidelity be tuned independently. (That was my reasoning at the time. The path I actually took turned out differently; see the end.)
The constraint check should have come before the prototyping. Once I’d written down “the agi-up approach assumes native-resolution output; my pipeline assumes upscale-from-native; the conflict is over line thickness,” I would have known going in that the structural ideas couldn’t transfer and only specific rules could. That would have saved a couple of strategy classes’ worth of code.
What I’m taking forward#
V9 and V10 are archived alongside v6/v7/v8 in my repo as deprecated references. The shared infrastructure (junction-seal.ts, the cmdType/cmdId buffers) stays; it’s useful scaffolding for any future experiment that wants to reason about command provenance.
The one rule I borrowed (diagonal-flanking suppression) is solid, just too narrow for what V5 was already catching with its pixel-pattern closure. If I revisit native-resolution rendering as part of a future dual-layer split, I expect more of the agi-up approach to suddenly become applicable.
A note to OldManYellsAtCode1#
If you’re reading this: thank you for putting agi-up and the walkthrough video out there. Your diagonal-flanking suppression rule gave me a geometric condition I hadn’t articulated before, and, tightened to true X-junctions, it directly improved my own renderer.
The takeaway for anyone else trying this#
If you’re borrowing ideas from a renderer built around different assumptions, do the constraint check first. Identify which parts of the reference depend on assumptions your pipeline has already ruled out and which parts don’t. Most of the time, the transferable insight from a different-paradigm reference is small and surgical (a specific rule or observation), not the architectural skeleton. That’s no criticism of either codebase; it’s just what cross-paradigm porting looks like when both designs are well-thought-out within their own constraints.
V5’s blend variant is still the strategy I ship for game rendering: omyac renders only the visible layer, so it can’t replace snap for the priority and control layers the game logic needs. But the next real move turned out not to be the dual-layer split this experiment pointed at: it was porting the whole anchor graph, which now beats snap-blend on every PIC I’ve tested as a visible-layer upscaler.