katiahayati/lucasartsifier

Static analysis for Sierra adventure games: finds softlocks by abstract interpretation of decompiled SCI scripts, derives and verifies guards, recompiles them into the game

50

stars

594

commits

Python

primary language

Aug 25, 2026

updated

abstract-interpretation
adventure-games
program-analysis
retrogaming
reverse-engineering
sci
scummvm
sierra
static-analysis

README

Sierra softlock analyzer

Static analysis for 30-year-old adventure games. This tool decompiles a Sierra SCI game, abstract-interprets the decompiled scripts into a graph of guarded room transitions, item movements and plot-flag writes, finds the softlocks — states where the game still accepts input but victory has quietly become impossible — and derives, verifies, compiles and installs guards that prevent them. Nothing about any title is declared: the start room, the victory room, the death signal and the debug flags are all discovered from the game's own code.

Sierra games, unlike LucasArts ones, let you get stuck. Forget the sunscreen in Los Angeles, board the cruise ship, and you die days later on a raft with no way back. This finds these traps automatically and blocks the crossing that causes them — at the last moment you can still comply.

Four games analyzed and play-tested — Leisure Suit Larry 2 (SCI0, 1988), King's Quest IV (SCI0, 1988), King's Quest VI (SCI1.1, 1992), and Laura Bow 2 (SCI1.1, 1992) — same engine, no game-specific analysis code.

Demo

King's Quest IV, patched: the post-whale island.

Demo: King's Quest IV, patched

The tool detects that once you leave the island, you can't come back, and that you need the Golden Bridle later for the unicorn. It injects a "Not yet!" stock refusal if the player attempts to ride the dolphin without the Bridle. Once Rosella gets the Bridle the exit is allowed to happen.

The thirty-second version

Abridged from a real run on Leisure Suit Larry 2 (python3 -m pipeline <game>):

[2] ANALYZE
    anchors: start rm11, victory [86]  (discovered)
    death signal: global101 == 1001, debug globals: [14, 100]  (derived)
    101 rooms, 27 strongly-connected components, 40 gating registers
    softlocks: 15 items + 1 disjunctive group(s)
      - Sunscreen
      ...
[3] DERIVE
    rm38 -> rm131: (and (gEgo has: 11) (gEgo has: 12) (gEgo has: 14) (gEgo has: 15))
    rm57 -> rm58: (and (gEgo has: 21) (gEgo has: 24) (gEgo has: 25) (gEgo has: 26))
    rm79 -> rm80: (or (gEgo has: 30) (gEgo has: 31))
    rm131 -> rm138: (not (gEgo has: 13))
    rm63: delete `(gEgo put: 21 -1)` (Hair_Rejuvenator)
    verifying against the guarded model...
    fixed 15 + 1 group(s); NEW softlocks introduced: none
[4] PATCH
    compiled 117/118 scripts
    script.000  Main  10790 bytes
    script.057  rm57  2938 bytes
    ...
Done. 10 patch files in build/patch

The analyzer discovered the ship boarding as a one-way crossing, derived which items must cross with you, re-verified the guarded model to prove the guards introduce no new softlocks, and recompiled the touched scripts into Sierra's own loose-patch format.

Note rm131 -> rm138: (not (gEgo has: 13)). Guards carry negative literals too: the Spinach Dip is fatal to be holding in rm138, so the fix is to refuse the crossing while you still have it — placed where you can still throw it overboard, because demanding you drop something you can no longer drop is a wall, which this project treats as worse than the bug. The pipeline refuses to emit anything if the guards fail verification, or if a script it edited will not compile.

A patched game plays normally — the patch mechanism is how Sierra shipped its own bug fixes, and the originals are never modified (delete the patch files to revert). You can set the guard behavior in-game: Full prevents every dangerous action; Lite prevents it once, then allows it with a warning; Off turns the guards off (currently that picker is not showing in Laura Bow 2 or King's Quest 5, so the guards default to Full).

What counts as a softlock? (or: Caveat Player)

Some deaths are deliberately left in — the ones you can still avoid from where you are. The analysis distinguishes unwinnable states from avoidable deaths by reachability, not by death conditions. In Leisure Suit Larry 2, walking onto the KGB beach without the full disguise kills you. Some pieces of the disguise exist only on the cruise ship, so the analyzer refuses to let you leave the ship without them. But the rest is obtainable on the island — from the very place the death occurs — so that death stays in: it is how Sierra games hint at what you need to do. As Al Lowe says, "Save Early, Save Often!"

Status

Four games done, spanning the engine's two major eras (SCI0 1988 → SCI1.1 1992), with nothing declared per title — start room, victory room, death signal and debug flags are all derived from each game's own code.

gameenginestatusnotable features
Leisure Suit Larry 2 (1988)SCI0done & testedthe Spinach Dip: fatal to carry, so the guard is a negative literal, placed while you can still ditch it
King's Quest IV (1988)SCI0done & testedthe real-time night clock; the whale — random events guarded by arming them only when survivable
King's Quest VI (1992)SCI1.1done & testedthe two ending paths massively complicate analysis; guarding the start of the wedding (a timer) until necessary items are in hand
Laura Bow 2 (1992)SCI1.1done & testedthe act structure: the plot clock is a register, act breaks are one-way, demands ride the act-flip interceptor
King's Quest V (1990)SCI1-middledone & testedthe village market (matching payments to merchants, so detection becomes a matching problem); dangerous encounters guarded by allowing them to occur only when survivable; the cat&mouse interception replays until the player gets it right

How it works, briefly

  1. Decompile the game binary to a typed control-flow AST (JSON IR).
  2. Abstract-interpret that AST, composing path conditions into a game graph: guarded movement edges, item acquisitions, item losses, register writes. Room art (PIC/VIEW) and obstacle polygons are read too, since some gates are geometric and exist nowhere in the script.
  3. Condense the graph into strongly-connected components — regions you can wander freely. Only the one-way edges between them can strand you, which is what makes the problem finite.
  4. Find strandings: an item obtainable before a crossing, unavailable after, still needed beyond.
  5. Derive a guard from the winning region — the condition under which the goal is still reachable — and place it at the last point where the player can still comply. Item-wasting dead ends are neutralized separately, with a "Just kidding!" message that prevents you from wasting the needed item, and no score penalty.
  6. Recompile and emit. The patched game is now playable normally (e.g. in ScummVM or DOSBox).

Longer version in docs/HOW-IT-WORKS.md; per-file map in docs/ARCHITECTURE.md; current KQ6 status in docs/KQ6-STATUS.md; LB2's derivation log in docs/LB2-ORACLE.md.

The toolchain

Steps 1 and 6 stand on two excellent existing projects, driven headless:

  • Decompilation is sci-tools (sluicebox, MIT). We maintain a fork whose json-ir branch adds a second emitter beside the .sc source output: the typed control-flow AST as JSON, which is what the analysis consumes. The decompilation logic itself is untouched.
  • Compilation is SCICompanion's script compiler (Philip Fortier, GPL-2.0+), which we ported to build and run headless on Linux — tools/scicompile/ is a small CLI plus a compatibility layer that replaces the MFC/Windows surface, calling the real parser, class browser, resource map and code generator (GenerateScriptResource). The vendor tree is cloned at build time and never edited; a handful of files are patched as a build step for MSVC-only constructs, with every change documented in tools/scicompile/BUILD_NOTES.md. Each guarded script the pipeline emits is compiled by the same code paths SCICompanion uses in its IDE, then wrapped in Sierra's loose-patch header.

Install

The analysis is Python 3 with no third-party packages at allsrc/ imports only the standard library. What needs installing is the two external toolchains it drives: the decompiler (C#) and the SCI compiler (C++), both built here from source.

Prerequisites

sudo apt install python3 git cmake g++ make dotnet-sdk-8.0     # Debian/Ubuntu
whatwhyverified against
Python 3.12the analysis and the tests (src/)3.12.3
.NET SDK 8builds sci-tools, which decompiles the game8.0.129
cmake ≥ 3.16, a C++14 compiler, makebuilds scicompile, which recompiles the patched scriptscmake 3.28.3, g++ 13.3
gitboth vendored trees are cloned at build time, not bundled2.43

Verified from scratch in a clean ubuntu:24.04 container: the packages above, the two builds below, a full pipeline run and the game-independent tests — see the log recipe in docs/HOW-IT-WORKS.md.

One-time build

git clone https://github.com/katiahayati/lucasartsifier && cd lucasartsifier

# 1. the decompiler. Clones our sci-tools fork into vendor/, builds it, and decompiles
#    GAME into build/ir -- both a .sc source tree and the typed-AST JSON IR.
tools/sci-tools-fork/build.sh /path/to/game

# 2. the compiler: SCICompanion's, ported headless. Its source is cloned and never modified;
#    the port lives beside it in tools/scicompile/{compat,patched}.
git clone --depth 1 https://github.com/icefallgames/SCICompanion vendor/SCICompanion
cmake -S tools/scicompile -B tools/scicompile/build
cmake --build tools/scicompile/build -j

Step 1 alone is enough to analyze a game (--report); step 2 is what turns the derived guards into patch files. vendor/ is gitignored — no third-party source and no game data is redistributed here.

Run it

You supply your own copy of a game; none is included. The commands run from src/:

cd src
python3 -m pipeline /path/to/game            # decompile -> analyze -> derive -> patch
python3 -m pipeline /path/to/game --report   # analyze only, write nothing
python3 -m pipeline /path/to/game --skip-decompile    # reuse the IR under build/ir

Output lands in build/patch/ as loose patch files:

cp build/patch/script.* /copy/of/game/    # install
rm  /copy/of/game/script.0*               # revert

Loose script.NNN files override the mapped resource, so RESOURCE.MAP and the volumes are never modified and the patch reverts by deleting files. Point it at a copy of the game, never at your only one.

Running the tests

python3 tools/run_tests.py              # the whole suite (~21 min with every model cold)

docs/TESTING.md has the rest: why some checks are RED on purpose, the three regression nets and the different questions they answer, how to measure a change against the full output surface before committing it, and how to drive a patched build under ScummVM with nobody at the keyboard.

Layout

src/                      the analysis (Python 3, standard library only)
src/testdata/             the frozen surfaces: two goldens + the watched pair
tools/run_tests.py        the test runner (docs/TESTING.md)
tools/drive_scummvm.py    play-test a patched build with nobody at the keyboard
tools/kq6_panel_probe.py    ... a driver script: cold start -> KQ6's guard control
tools/sci-tools-fork/     build.sh for our JSON-IR fork of sci-tools               [C#]
tools/scicompile/         headless Linux port of SCICompanion's compiler     [C++, GPL-2.0+]
docs/                     how it works, architecture, testing, per-game status, licensing
docs/reviews/             contextless reviews of tagged releases, verbatim
docs/archive/             superseded plans, kept for their measurements      [see its README]
vendor/                   cloned at build time, never committed (see Install)

Per-game configuration (src/config.py) is filesystem paths and a display name — nothing about the game itself. Start room, victory rooms, the death signal and the debug flags all have override fields there, and every game leaves them empty: the pipeline derives all four from the game's own code (see src/anchors.py). A new title needs no config entry at all — config.by_name() picks up any game whose decompiled IR sits under build/sweep/<name>/.

Future work

  • State explosion in Quest For Glory games. QFG games have SO MUCH going on that the analyzer cannot complete. I suspect we can fix that by abstracting away from player stats, combat, and health consumables (rations, etc.), but that work has not been done yet.
  • Full end-to-end playtesting. No game has had a single continuous start-to-finish run on a patched build yet. King's Quest V comes closest: every row of its test plan has been played on the patched game, the endgame included, and that is where several of its guards were found, corrected, or retired — a reminder that static reading and play disagree more often than is comfortable. The other four are extensively tested where patched.
  • More games! There is no game-specific code in the engine, but Sierra shipped a lot of game-specific code in each game, so every new title has so far required extending the analysis. Hopefully at some point this converges to zero.
  • AGI. AGI games should definitely be included, but that work is not started yet.

Licensing

MIT, except tools/scicompile/ which is GPL-2.0-or-later — it contains modified SCICompanion source and links its compiler, so it is a derivative work. See LICENSE, NOTICE, and docs/LICENSING.md.

Built on sci-tools (sluicebox, MIT) and SCICompanion (Philip Fortier, GPL-2.0+). No game data is included in this repository under any terms.

Contributors

katiahayati

594 commits

katiahayati/lucasartsifier

Static analysis for Sierra adventure games: finds softlocks by abstract interpretation of decompiled SCI scripts, derives and verifies guards, recompiles them into the game

50

stars

594

commits

Python

primary language

Aug 25, 2026

updated

abstract-interpretation
adventure-games
program-analysis
retrogaming
reverse-engineering
sci
scummvm
sierra
static-analysis

README

Sierra softlock analyzer

Static analysis for 30-year-old adventure games. This tool decompiles a Sierra SCI game, abstract-interprets the decompiled scripts into a graph of guarded room transitions, item movements and plot-flag writes, finds the softlocks — states where the game still accepts input but victory has quietly become impossible — and derives, verifies, compiles and installs guards that prevent them. Nothing about any title is declared: the start room, the victory room, the death signal and the debug flags are all discovered from the game's own code.

Sierra games, unlike LucasArts ones, let you get stuck. Forget the sunscreen in Los Angeles, board the cruise ship, and you die days later on a raft with no way back. This finds these traps automatically and blocks the crossing that causes them — at the last moment you can still comply.

Four games analyzed and play-tested — Leisure Suit Larry 2 (SCI0, 1988), King's Quest IV (SCI0, 1988), King's Quest VI (SCI1.1, 1992), and Laura Bow 2 (SCI1.1, 1992) — same engine, no game-specific analysis code.

Demo

King's Quest IV, patched: the post-whale island.

Demo: King's Quest IV, patched

The tool detects that once you leave the island, you can't come back, and that you need the Golden Bridle later for the unicorn. It injects a "Not yet!" stock refusal if the player attempts to ride the dolphin without the Bridle. Once Rosella gets the Bridle the exit is allowed to happen.

The thirty-second version

Abridged from a real run on Leisure Suit Larry 2 (python3 -m pipeline <game>):

[2] ANALYZE
    anchors: start rm11, victory [86]  (discovered)
    death signal: global101 == 1001, debug globals: [14, 100]  (derived)
    101 rooms, 27 strongly-connected components, 40 gating registers
    softlocks: 15 items + 1 disjunctive group(s)
      - Sunscreen
      ...
[3] DERIVE
    rm38 -> rm131: (and (gEgo has: 11) (gEgo has: 12) (gEgo has: 14) (gEgo has: 15))
    rm57 -> rm58: (and (gEgo has: 21) (gEgo has: 24) (gEgo has: 25) (gEgo has: 26))
    rm79 -> rm80: (or (gEgo has: 30) (gEgo has: 31))
    rm131 -> rm138: (not (gEgo has: 13))
    rm63: delete `(gEgo put: 21 -1)` (Hair_Rejuvenator)
    verifying against the guarded model...
    fixed 15 + 1 group(s); NEW softlocks introduced: none
[4] PATCH
    compiled 117/118 scripts
    script.000  Main  10790 bytes
    script.057  rm57  2938 bytes
    ...
Done. 10 patch files in build/patch

The analyzer discovered the ship boarding as a one-way crossing, derived which items must cross with you, re-verified the guarded model to prove the guards introduce no new softlocks, and recompiled the touched scripts into Sierra's own loose-patch format.

Note rm131 -> rm138: (not (gEgo has: 13)). Guards carry negative literals too: the Spinach Dip is fatal to be holding in rm138, so the fix is to refuse the crossing while you still have it — placed where you can still throw it overboard, because demanding you drop something you can no longer drop is a wall, which this project treats as worse than the bug. The pipeline refuses to emit anything if the guards fail verification, or if a script it edited will not compile.

A patched game plays normally — the patch mechanism is how Sierra shipped its own bug fixes, and the originals are never modified (delete the patch files to revert). You can set the guard behavior in-game: Full prevents every dangerous action; Lite prevents it once, then allows it with a warning; Off turns the guards off (currently that picker is not showing in Laura Bow 2 or King's Quest 5, so the guards default to Full).

What counts as a softlock? (or: Caveat Player)

Some deaths are deliberately left in — the ones you can still avoid from where you are. The analysis distinguishes unwinnable states from avoidable deaths by reachability, not by death conditions. In Leisure Suit Larry 2, walking onto the KGB beach without the full disguise kills you. Some pieces of the disguise exist only on the cruise ship, so the analyzer refuses to let you leave the ship without them. But the rest is obtainable on the island — from the very place the death occurs — so that death stays in: it is how Sierra games hint at what you need to do. As Al Lowe says, "Save Early, Save Often!"

Status

Four games done, spanning the engine's two major eras (SCI0 1988 → SCI1.1 1992), with nothing declared per title — start room, victory room, death signal and debug flags are all derived from each game's own code.

gameenginestatusnotable features
Leisure Suit Larry 2 (1988)SCI0done & testedthe Spinach Dip: fatal to carry, so the guard is a negative literal, placed while you can still ditch it
King's Quest IV (1988)SCI0done & testedthe real-time night clock; the whale — random events guarded by arming them only when survivable
King's Quest VI (1992)SCI1.1done & testedthe two ending paths massively complicate analysis; guarding the start of the wedding (a timer) until necessary items are in hand
Laura Bow 2 (1992)SCI1.1done & testedthe act structure: the plot clock is a register, act breaks are one-way, demands ride the act-flip interceptor
King's Quest V (1990)SCI1-middledone & testedthe village market (matching payments to merchants, so detection becomes a matching problem); dangerous encounters guarded by allowing them to occur only when survivable; the cat&mouse interception replays until the player gets it right

How it works, briefly

  1. Decompile the game binary to a typed control-flow AST (JSON IR).
  2. Abstract-interpret that AST, composing path conditions into a game graph: guarded movement edges, item acquisitions, item losses, register writes. Room art (PIC/VIEW) and obstacle polygons are read too, since some gates are geometric and exist nowhere in the script.
  3. Condense the graph into strongly-connected components — regions you can wander freely. Only the one-way edges between them can strand you, which is what makes the problem finite.
  4. Find strandings: an item obtainable before a crossing, unavailable after, still needed beyond.
  5. Derive a guard from the winning region — the condition under which the goal is still reachable — and place it at the last point where the player can still comply. Item-wasting dead ends are neutralized separately, with a "Just kidding!" message that prevents you from wasting the needed item, and no score penalty.
  6. Recompile and emit. The patched game is now playable normally (e.g. in ScummVM or DOSBox).

Longer version in docs/HOW-IT-WORKS.md; per-file map in docs/ARCHITECTURE.md; current KQ6 status in docs/KQ6-STATUS.md; LB2's derivation log in docs/LB2-ORACLE.md.

The toolchain

Steps 1 and 6 stand on two excellent existing projects, driven headless:

  • Decompilation is sci-tools (sluicebox, MIT). We maintain a fork whose json-ir branch adds a second emitter beside the .sc source output: the typed control-flow AST as JSON, which is what the analysis consumes. The decompilation logic itself is untouched.
  • Compilation is SCICompanion's script compiler (Philip Fortier, GPL-2.0+), which we ported to build and run headless on Linux — tools/scicompile/ is a small CLI plus a compatibility layer that replaces the MFC/Windows surface, calling the real parser, class browser, resource map and code generator (GenerateScriptResource). The vendor tree is cloned at build time and never edited; a handful of files are patched as a build step for MSVC-only constructs, with every change documented in tools/scicompile/BUILD_NOTES.md. Each guarded script the pipeline emits is compiled by the same code paths SCICompanion uses in its IDE, then wrapped in Sierra's loose-patch header.

Install

The analysis is Python 3 with no third-party packages at allsrc/ imports only the standard library. What needs installing is the two external toolchains it drives: the decompiler (C#) and the SCI compiler (C++), both built here from source.

Prerequisites

sudo apt install python3 git cmake g++ make dotnet-sdk-8.0     # Debian/Ubuntu
whatwhyverified against
Python 3.12the analysis and the tests (src/)3.12.3
.NET SDK 8builds sci-tools, which decompiles the game8.0.129
cmake ≥ 3.16, a C++14 compiler, makebuilds scicompile, which recompiles the patched scriptscmake 3.28.3, g++ 13.3
gitboth vendored trees are cloned at build time, not bundled2.43

Verified from scratch in a clean ubuntu:24.04 container: the packages above, the two builds below, a full pipeline run and the game-independent tests — see the log recipe in docs/HOW-IT-WORKS.md.

One-time build

git clone https://github.com/katiahayati/lucasartsifier && cd lucasartsifier

# 1. the decompiler. Clones our sci-tools fork into vendor/, builds it, and decompiles
#    GAME into build/ir -- both a .sc source tree and the typed-AST JSON IR.
tools/sci-tools-fork/build.sh /path/to/game

# 2. the compiler: SCICompanion's, ported headless. Its source is cloned and never modified;
#    the port lives beside it in tools/scicompile/{compat,patched}.
git clone --depth 1 https://github.com/icefallgames/SCICompanion vendor/SCICompanion
cmake -S tools/scicompile -B tools/scicompile/build
cmake --build tools/scicompile/build -j

Step 1 alone is enough to analyze a game (--report); step 2 is what turns the derived guards into patch files. vendor/ is gitignored — no third-party source and no game data is redistributed here.

Run it

You supply your own copy of a game; none is included. The commands run from src/:

cd src
python3 -m pipeline /path/to/game            # decompile -> analyze -> derive -> patch
python3 -m pipeline /path/to/game --report   # analyze only, write nothing
python3 -m pipeline /path/to/game --skip-decompile    # reuse the IR under build/ir

Output lands in build/patch/ as loose patch files:

cp build/patch/script.* /copy/of/game/    # install
rm  /copy/of/game/script.0*               # revert

Loose script.NNN files override the mapped resource, so RESOURCE.MAP and the volumes are never modified and the patch reverts by deleting files. Point it at a copy of the game, never at your only one.

Running the tests

python3 tools/run_tests.py              # the whole suite (~21 min with every model cold)

docs/TESTING.md has the rest: why some checks are RED on purpose, the three regression nets and the different questions they answer, how to measure a change against the full output surface before committing it, and how to drive a patched build under ScummVM with nobody at the keyboard.

Layout

src/                      the analysis (Python 3, standard library only)
src/testdata/             the frozen surfaces: two goldens + the watched pair
tools/run_tests.py        the test runner (docs/TESTING.md)
tools/drive_scummvm.py    play-test a patched build with nobody at the keyboard
tools/kq6_panel_probe.py    ... a driver script: cold start -> KQ6's guard control
tools/sci-tools-fork/     build.sh for our JSON-IR fork of sci-tools               [C#]
tools/scicompile/         headless Linux port of SCICompanion's compiler     [C++, GPL-2.0+]
docs/                     how it works, architecture, testing, per-game status, licensing
docs/reviews/             contextless reviews of tagged releases, verbatim
docs/archive/             superseded plans, kept for their measurements      [see its README]
vendor/                   cloned at build time, never committed (see Install)

Per-game configuration (src/config.py) is filesystem paths and a display name — nothing about the game itself. Start room, victory rooms, the death signal and the debug flags all have override fields there, and every game leaves them empty: the pipeline derives all four from the game's own code (see src/anchors.py). A new title needs no config entry at all — config.by_name() picks up any game whose decompiled IR sits under build/sweep/<name>/.

Future work

  • State explosion in Quest For Glory games. QFG games have SO MUCH going on that the analyzer cannot complete. I suspect we can fix that by abstracting away from player stats, combat, and health consumables (rations, etc.), but that work has not been done yet.
  • Full end-to-end playtesting. No game has had a single continuous start-to-finish run on a patched build yet. King's Quest V comes closest: every row of its test plan has been played on the patched game, the endgame included, and that is where several of its guards were found, corrected, or retired — a reminder that static reading and play disagree more often than is comfortable. The other four are extensively tested where patched.
  • More games! There is no game-specific code in the engine, but Sierra shipped a lot of game-specific code in each game, so every new title has so far required extending the analysis. Hopefully at some point this converges to zero.
  • AGI. AGI games should definitely be included, but that work is not started yet.

Licensing

MIT, except tools/scicompile/ which is GPL-2.0-or-later — it contains modified SCICompanion source and links its compiler, so it is a derivative work. See LICENSE, NOTICE, and docs/LICENSING.md.

Built on sci-tools (sluicebox, MIT) and SCICompanion (Philip Fortier, GPL-2.0+). No game data is included in this repository under any terms.

Contributors

katiahayati

594 commits

Languages

Python

72.1%

C++

27.4%