Skip to content

Quackster/LibreShockwave

Repository files navigation

LibreShockwave SDK

Website

A Java library for parsing Macromedia/Adobe Director and Shockwave files (.dir, .dxr, .dcr, .cct, .cst).

Requirements

  • Java 21 or later

Building

./gradlew build

Supported Formats

  • RIFX container (big-endian and little-endian)
  • Afterburner-compressed files (.dcr, .cct)
  • Director versions 4 through 12

Capabilities

Reading

  • Cast members (bitmaps, text, scripts, sounds, shapes, palettes, fonts)
  • Lingo bytecode with symbol resolution
  • Score/timeline data (frames, channels, labels, behaviour intervals)
  • File metadata (stage dimensions, tempo, version)

Asset Extraction

  • Bitmaps: 1/2/4/8/16/32-bit depths, palette support, PNG export
  • Text: Field (type 3) and Text (type 12) cast members via STXT chunks
  • Sound: PCM to WAV conversion, MP3 extraction, IMA ADPCM decoding
  • Palettes: Built-in Director palettes and custom CLUT chunks
  • Fonts: PFR1 (Portable Font Resource) extraction from XMED chunks, export to TrueType (.ttf)

Writing

  • Save to uncompressed RIFX format
  • Remove protection from protected files
  • Decompile and embed Lingo source into cast members

Player & Lingo VM

Note: The Lingo VM, desktop player, and WASM player are under active development and are not production-ready. Expect missing features, incomplete Lingo coverage, and breaking changes.

LibreShockwave includes a Lingo bytecode virtual machine and player that can load and run Director movies. The VM executes compiled Lingo scripts, handles score playback, sprite rendering, and external cast loading — bringing .dcr and .dir files back to life.

Try the live demo → — a nightly build of the web player is deployed and ready to use. Load any .dcr or .dir file to test it in your browser.

The player is available in two forms:

  • Desktop (player-swing) — Swing-based UI with an integrated Lingo debugger
  • Web (player-wasm) — Compiled to WebAssembly via TeaVM, runs in any modern browser

All player functionality is decoupled from the SDK and VM via the player-core module, which provides platform-independent playback logic (score traversal, event dispatch, sprite management, bitmap decoding).

java_m4YLpAnayh

Using player-core as a Library

The player-core module provides platform-independent playback logic with no UI dependencies. Use it to build custom players (JavaFX, headless renderer, server-side processor, etc.).

Dependency

implementation project(':player-core')  // transitively includes :vm and :sdk

Minimal Example

import com.libreshockwave.DirectorFile;
import com.libreshockwave.bitmap.Bitmap;
import com.libreshockwave.player.Player;
import com.libreshockwave.player.render.FrameSnapshot;

DirectorFile file = DirectorFile.load(Path.of("movie.dcr"));
Player player = new Player(file);
player.play();

// Game loop
while (player.tick()) {
    FrameSnapshot snap = player.getFrameSnapshot();
    Bitmap frame = snap.renderFrame();              // composites all sprites with ink effects
    BufferedImage image = frame.toBufferedImage();   // ready to draw or save
}

player.shutdown();

Each call to tick() advances one frame and returns true while the movie is still playing. renderFrame() composites all sprites (bitmap, text, shape) with ink effects into a single image using pure software rendering — no AWT dependency required.

Custom networking

For environments without java.net.http (e.g. WASM, Android), pass a NetProvider to the constructor:

Player player = new Player(file, new NetBuiltins.NetProvider() {
    public int preloadNetThing(String url) { /* start async fetch, return task ID */ }
    public int postNetText(String url, String postData) { /* POST, return task ID */ }
    public boolean netDone(Integer taskId) { /* true when complete */ }
    public String netTextResult(Integer taskId) { /* response body */ }
    public int netError(Integer taskId) { /* 0 = OK, negative = error */ }
    public String getStreamStatus(Integer taskId) { /* "Connecting", "Complete", etc. */ }
});
External parameters

Shockwave movies read <PARAM> tags from the embedding HTML. Pass these before calling play():

player.setExternalParams(Map.of(
    "sw1", "external.variables.txt=http://example.com/vars.txt",
    "sw2", "connection.info.host=127.0.0.1"
));
Event listeners
// Player events (enterFrame, mouseDown, etc.)
player.setEventListener(event -> {
    System.out.println(event.event() + " at frame " + event.frame());
});

// Notified when an external cast finishes loading
player.setCastLoadedListener(() -> {
    System.out.println("A cast finished loading");
});
Error handling
// Listen for Lingo script errors
player.setErrorListener((message, exception) -> {
    System.err.println("Lingo error: " + message);

    // The exception carries the Lingo call stack at the point of the error
    String callStack = exception.formatLingoCallStack();
    if (callStack != null) {
        System.err.println(callStack);
    }

    // Or inspect individual frames
    for (var frame : exception.getLingoCallStack()) {
        System.out.println(frame.handlerName() + " in " + frame.scriptName()
            + " [bytecode " + frame.bytecodeIndex() + "]");
    }
});

You can also get the call stack at any time during execution (e.g. from a TraceListener or breakpoint):

// Get the live Lingo call stack (empty list when no handlers are executing)
List<LingoVM.CallStackFrame> stack = player.getLingoCallStack();

// Or as a formatted string
String formatted = player.formatLingoCallStack();
Debug playback

Debug playback controls put output, error call stacks, and diagnostic logging. It is enabled by default.

// Disable debug output (suppresses put/error logging to stderr)
DebugConfig.setDebugPlaybackEnabled(false);

// Re-enable
DebugConfig.setDebugPlaybackEnabled(true);

For bytecode-level debugging (breakpoints, stepping, watch expressions), use the desktop player's built-in debugger or attach a DebugControllerApi:

DebugController debugger = new DebugController();
player.setDebugController(debugger);

// Add a breakpoint (scriptId, handlerName, bytecodeOffset)
debugger.addBreakpoint(42, "enterFrame", 0);

// Step controls (when paused at a breakpoint)
debugger.stepInto();
debugger.stepOver();
debugger.stepOut();
debugger.continueExecution();

// Inspect state when paused
DebugSnapshot snap = debugger.getCurrentSnapshot();
snap.locals();     // local variables
snap.globals();    // global variables
snap.stack();      // operand stack
snap.callStack();  // call frames
Lifecycle
Method Description
play() Prepare the movie and begin playback
tick() Advance one frame; returns false when the movie has stopped
pause() Pause playback (keeps state)
resume() Resume after pause
stop() Stop playback and reset to frame 1
shutdown() Release all resources (thread pools, caches)

Screenshots

Cast Extractor

A GUI tool for browsing and extracting assets from Director files (available on the releases page).

Cast Extractor

Usage

Loading a File

import com.libreshockwave.DirectorFile;
import java.nio.file.Path;

// From file path
DirectorFile file = DirectorFile.load(Path.of("movie.dcr"));

// From byte array
DirectorFile file = DirectorFile.load(bytes);
Accessing metadata
DirectorFile file = DirectorFile.load(Path.of("movie.dcr"));

file.isAfterburner();                    // true if compressed
file.getEndian();                        // BIG_ENDIAN (Mac) or LITTLE_ENDIAN (Windows)
file.getStageWidth();                    // stage width in pixels
file.getStageHeight();                   // stage height in pixels
file.getTempo();                         // frames per second
file.getConfig().directorVersion();      // internal version number
file.getChannelCount();                  // sprite channels (48-1000 depending on version)
Iterating cast members
for (CastMemberChunk member : file.getCastMembers()) {
    int id = member.id();
    String name = member.name();

    if (member.isBitmap()) { /* ... */ }
    if (member.isScript()) { /* ... */ }
    if (member.isSound()) { /* ... */ }
    if (member.isField()) { /* old-style text */ }
    if (member.isText()) { /* rich text */ }
    if (member.hasTextContent()) { /* either field or text */ }
}
Extracting bitmaps
for (CastMemberChunk member : file.getCastMembers()) {
    if (!member.isBitmap()) continue;

    file.decodeBitmap(member).ifPresent(bitmap -> {
        BufferedImage image = bitmap.toBufferedImage();
        ImageIO.write(image, "PNG", new File(member.name() + ".png"));
    });
}
Extracting text
KeyTableChunk keyTable = file.getKeyTable();

for (CastMemberChunk member : file.getCastMembers()) {
    if (!member.hasTextContent()) continue;

    for (KeyTableChunk.KeyTableEntry entry : keyTable.getEntriesForOwner(member.id())) {
        if (entry.fourccString().equals("STXT")) {
            Chunk chunk = file.getChunk(entry.sectionId());
            if (chunk instanceof TextChunk textChunk) {
                String text = textChunk.text();
            }
            break;
        }
    }
}
Extracting sounds
import com.libreshockwave.audio.SoundConverter;

for (CastMemberChunk member : file.getCastMembers()) {
    if (!member.isSound()) continue;

    for (KeyTableChunk.KeyTableEntry entry : keyTable.getEntriesForOwner(member.id())) {
        if (entry.fourccString().equals("snd ")) {
            SoundChunk sound = (SoundChunk) file.getChunk(entry.sectionId());

            if (sound.isMp3()) {
                byte[] mp3 = SoundConverter.extractMp3(sound);
            } else {
                byte[] wav = SoundConverter.toWav(sound);
            }
            break;
        }
    }
}
Extracting fonts (PFR1 → TTF)

Director files can embed fonts as PFR1 (Portable Font Resource) data inside XMED chunks attached to OLE-type cast members. LibreShockwave can parse these and convert them to standard TrueType (.ttf) files.

import com.libreshockwave.font.Pfr1Font;
import com.libreshockwave.font.Pfr1TtfConverter;

// Find XMED chunks with PFR1 data
KeyTableChunk keyTable = file.getKeyTable();
int xmedFourcc = ChunkType.XMED.getFourCC();

for (CastMemberChunk member : file.getCastMembers()) {
    var entry = keyTable.findEntry(member.id(), xmedFourcc);
    if (entry == null) continue;

    Chunk chunk = file.getChunk(entry.sectionId());
    if (!(chunk instanceof RawChunk raw)) continue;

    byte[] data = raw.data();
    if (data == null || data.length < 4) continue;
    if (data[0] != 'P' || data[1] != 'F' || data[2] != 'R' || data[3] != '1') continue;

    // Parse PFR1 and convert to TTF
    Pfr1Font font = Pfr1Font.parse(data);
    byte[] ttfBytes = Pfr1TtfConverter.convert(font, font.fontName);
    Files.write(Path.of(member.name() + ".ttf"), ttfBytes);
}

The player automatically detects PFR1 fonts when cast libraries load, converts them to TTF in memory, and registers them for pixel-perfect text rendering.

Accessing scripts and bytecode
ScriptNamesChunk names = file.getScriptNames();

for (ScriptChunk script : file.getScripts()) {
    // Script-level declarations
    List<String> globals = script.getGlobalNames(names);
    List<String> properties = script.getPropertyNames(names);

    for (ScriptChunk.Handler handler : script.handlers()) {
        String handlerName = names.getName(handler.nameId());
        int argCount = handler.argCount();
        int localCount = handler.localCount();

        // Argument and local variable names
        for (int id : handler.argNameIds()) {
            String argName = names.getName(id);
        }
        for (int id : handler.localNameIds()) {
            String localName = names.getName(id);
        }

        // Bytecode instructions
        for (ScriptChunk.Handler.Instruction instr : handler.instructions()) {
            int offset = instr.offset();
            Opcode opcode = instr.opcode();
            int argument = instr.argument();
        }
    }
}
Aggregating globals and properties
// All unique globals across all scripts
Set<String> allGlobals = file.getAllGlobalNames();

// All unique properties across all scripts
Set<String> allProperties = file.getAllPropertyNames();

// Detailed info per script
for (DirectorFile.ScriptInfo info : file.getScriptInfoList()) {
    info.scriptId();
    info.scriptName();
    info.scriptType();
    info.globals();
    info.properties();
    info.handlers();
}
Reading score data
if (file.hasScore()) {
    ScoreChunk score = file.getScoreChunk();
    int frames = score.getFrameCount();
    int channels = score.getChannelCount();

    // Frame labels
    FrameLabelsChunk labels = file.getFrameLabelsChunk();
    if (labels != null) {
        for (FrameLabelsChunk.FrameLabel label : labels.labels()) {
            int frameNum = label.frameNum();
            String labelName = label.label();
        }
    }

    // Behaviour intervals
    for (ScoreChunk.FrameInterval interval : score.frameIntervals()) {
        int start = interval.startFrame();
        int end = interval.endFrame();
        int scriptId = interval.scriptId();
    }
}
Accessing raw chunks
// All chunk metadata
for (DirectorFile.ChunkInfo info : file.getAllChunkInfo()) {
    int id = info.id();
    ChunkType type = info.type();
    int offset = info.offset();
    int length = info.length();
}

// Specific chunk by ID
Chunk chunk = file.getChunk(42);

// Type-safe chunk access
file.getChunk(42, BitmapChunk.class).ifPresent(bitmap -> {
    byte[] data = bitmap.data();
});
External cast files
for (String castPath : file.getExternalCastPaths()) {
    Path resolved = baseDir.resolve(castPath);
    if (Files.exists(resolved)) {
        DirectorFile castFile = DirectorFile.load(resolved);
    }
}
Saving files
// Load compressed/protected file
DirectorFile file = DirectorFile.load(Path.of("protected.dcr"));

// Save as unprotected RIFX (decompiles scripts automatically)
file.save(Path.of("unprotected.dir"));

// Or get bytes
byte[] rifxData = file.saveToBytes();

Web Player (player-wasm)

The player-wasm module compiles the player for the browser using TeaVM v0.13's standard WebAssembly backend. It produces a .wasm file with a JavaScript library that runs in all modern browsers.

WASM is a pure computation engine with zero @Import annotations — JS owns networking (fetch), canvas rendering, and the animation loop. All WASM execution runs in a Web Worker so slow Lingo scripts never block the main thread.

Building

./gradlew :player-wasm:generateWasm

This compiles the Java player to WebAssembly and assembles all files (WASM binary, JS runtime, HTML, CSS) into a single serveable directory at player-wasm/build/dist/.

Running locally

./gradlew :player-wasm:generateWasm
npx serve player-wasm/build/dist
# Open http://localhost:3000

Deploying

Copy the contents of player-wasm/build/dist/ to your web server. The included index.html is a ready-made player page with file picker, URL bar, transport controls, and a params editor.

Embedding in Any Web Page

Include shockwave-lib.js and add a <canvas>. That's it.

<canvas id="stage" width="640" height="480"></canvas>
<script src="shockwave-lib.js"></script>
<script>
  var player = LibreShockwave.create("stage");
  player.load("http://example.com/movie.dcr");
</script>

The following files must be served from the same directory as the script:

File Purpose
shockwave-lib.js Player library (the only <script> you need)
shockwave-worker.js Web Worker — runs the WASM engine off the main thread
player-wasm.wasm Compiled player engine
player-wasm.wasm-runtime.js TeaVM runtime (loaded by the worker automatically)
JavaScript API
// Create a player on a <canvas> element (by ID or element reference)
var player = LibreShockwave.create("my-canvas", {
    basePath:      "/wasm/",                 // where the WASM files live (auto-detected by default)
    params:        { sw1: "key=value" },     // Shockwave <PARAM> tags
    autoplay:      true,                     // start playing after load (default: true)
    remember:      true,                     // persist params in localStorage (default: false)
    debugPlayback: true,                     // enable put/error logging (default: true)
    onLoad:        function(info) {},        // { width, height, frameCount, tempo }
    onError:       function(msg) {},         // error message string
    onFrame:       function(frame, total) {} // called each frame
});

// Load a movie
player.load("http://localhost/movie.dcr");  // from URL
player.loadFile(fileInput.files[0]);        // from <input type="file">

// Playback
player.play();
player.pause();
player.stop();
player.goToFrame(10);
player.stepForward();
player.stepBackward();

// External parameters (Shockwave PARAM tags)
player.setParam("sw1", "external.variables.txt=http://localhost/gamedata/external_variables.txt");
player.setParams({ sw1: "...", sw2: "..." });

// State
player.getCurrentFrame();  // current frame number
player.getFrameCount();    // total frames

// Debugging
player.setDebugPlayback(true);   // enable/disable put output & error stack traces
player.getCallStack().then(function(stack) {
    console.log(stack);          // Lingo call stack (async, returns Promise<string>)
});

// Reset (terminates worker, creates fresh WASM instance)
player.reset().then(function() {
    player.load("http://localhost/movie.dcr");  // load on a clean slate
});

// Clean up
player.destroy();
Architecture
Main thread (shockwave-lib.js)            Worker (shockwave-worker.js)
──────────────────────────────            ────────────────────────────
fetch() .dcr file
  postMessage('loadMovie', bytes)  →      WasmEntry.loadMovie()
                                            → Player + QueuedNetProvider created
  postMessage('preloadCasts')      →      preloadCasts() + pumpNetworkCollect()
                                            → fetch() cast files
  ← postMessage('castsDone')

  postMessage('play')              →      WasmEntry.play()

requestAnimationFrame loop:
  postMessage('tick')              →      WasmEntry.tick()
                                            → Lingo VM executes (may be slow — OK
                                              because it's off the main thread)
                                            → pumpNetworkCollect()
                                              → fetch() any queued URLs
                                              → deliverFetchResult()
                                            → getFrameDataJson()
                                            → getBitmapData() for new members
  ← postMessage('frame', fd, bitmaps)
  createImageBitmap() + cache
  Canvas 2D drawImage()

Key design decisions:

  • No @Import — WASM never calls JS; JS polls for pending network requests
  • Web Worker — WASM tick runs off the main thread; Lingo scripts that take hundreds of ms during loading never cause requestAnimationFrame violations
  • Worker owns networking — fetch() runs in the worker; no relay through main thread
  • Zero-copy bitmap transfer — RGBA bytes sent from worker via Transferable; main thread caches ImageBitmap objects; only new/changed members are sent each frame
  • Single rendering path — sprite JSON + bitmap cache (no pixel buffer fallback)
  • Fallback URLs in JSON — worker handles retry logic (.cct → .cst on 404)
Module structure
player-wasm/
  build.gradle                          # TeaVM plugin config + assembleWasm task
  src/main/java/.../wasm/
    WasmEntry.java                      # All @Export methods (single entry point)
    WasmPlayer.java                     # Player wrapper (deferred play, tick resilience)
    QueuedNetProvider.java              # Polling-based NetProvider (no @Import)
    SpriteDataExporter.java             # Frame data JSON + bitmap cache
  src/main/resources/web/
    index.html                          # Player page with toolbar and transport controls
    shockwave-lib.js                    # Main-thread library: creates Worker, renders canvas
    shockwave-worker.js                 # Web Worker: WASM engine + network pump
    libreshockwave.css                  # Styling

Development Notes

TeaVM string switch — never use Java keywords as case labels

TeaVM 0.13 silently miscompiles switch statements that use Java keywords as string case labels. The case body is never entered at runtime even though equality holds in plain Java:

// BROKEN in TeaVM WASM — case "char" is silently skipped:
return switch (chunkType) {
    case "char" -> str.substring(start - 1, end);
    case "word" -> ...
};

// CORRECT — use if-else instead:
if ("char".equals(chunkType)) {
    return str.substring(start - 1, end);
} else if ("word".equals(chunkType)) {
    ...
}

Affected keywords include char, int, void, class, new, return, and any other Java reserved word. Use if-else chains with .equals() whenever a string variable might match a keyword at runtime.

TeaVM nested yield switch — avoid nested switch expressions

TeaVM 0.13 silently fails on nested switch expressions that use yield. When a yield switch(...) { ... } appears inside an outer switch expression, the inner switch crashes at WASM runtime with no error:

// BROKEN in TeaVM WASM — inner switch silently throws:
return switch (method) {
    case "count" -> {
        yield switch (chunkType) {
            case "char" -> Datum.of(text.length());
            case "word" -> Datum.of(trimmed.split("\\s+").length);
            default -> Datum.ZERO;
        };
    }
    default -> Datum.VOID;
};

// CORRECT — use if-else instead of the inner switch:
return switch (method) {
    case "count" -> {
        if ("char".equals(chunkType)) {
            yield Datum.of(text.length());
        } else if ("word".equals(chunkType)) {
            yield Datum.of(trimmed.split("\\s+").length);
        } else {
            yield Datum.ZERO;
        }
    }
    default -> Datum.VOID;
};

The outer switch expression with yield works fine — only the nesting of a second switch expression inside it causes the issue.

Multiuser Xtra — WebSocket proxy setup

Director movies that use the Multiuser Xtra need a WebSocket-to-TCP proxy because browsers can't open raw TCP sockets. websockify bridges WebSocket connections from the browser to the TCP server.

Each TCP port your movie connects to needs its own websockify instance. For example, Habbo connects to a game server (port 30001) and a MUS server (port 38101).

Installing websockify

pip install websockify

Quick start

# Proxy WebSocket :30001 → TCP 127.0.0.1:30087
python -m websockify --verbose --traffic 30001 127.0.0.1:30087

Replace ports as needed. The first argument is the WebSocket listen port, the second is the TCP target (host:port).

Running as a service (Linux — systemd)

Create /etc/systemd/system/websockify-game.service:

[Unit]
Description=websockify game proxy (WS :30001 → TCP :30087)
After=network.target

[Service]
ExecStart=/usr/bin/python3 -m websockify 30001 127.0.0.1:30087
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

Then enable and start:

sudo systemctl daemon-reload
sudo systemctl enable websockify-game
sudo systemctl start websockify-game

Repeat with a second unit file for each additional port (e.g. websockify-mus.service for the MUS connection).

Known Limitations

  • No mouse/keyboard event forwarding to Lingo VM (planned)
  • No Lingo debugger in WASM (available in desktop player)
  • 32-bit JPEG-based bitmaps (ediM+ALFA) render as placeholders

Tools

Cast Extractor GUI

./gradlew :sdk:extractCasts

Running Tests

# All unit tests
./gradlew :sdk:test :vm:test

SDK Tests (sdk/src/test/)

Class Description
DirectorFileTest Integration tests for loading and parsing Director files
DcrFileTest DCR file parsing with external cast support
SdkFeatureTest Comprehensive SDK feature tests (local files + HTTP)
Bitmap32BitTest 32-bit bitmap decoding
BitmapExtractTest Bitmap extraction by name from Director files
ScriptTypeTest Script type identification and classification
SoundExtractionTest Sound extraction (PCM → WAV, MP3)
Pfr1ToTtfTest PFR1 font → TTF conversion + pixel comparison against reference
Pfr1ParseTest PFR1 binary format parser diagnostics
# Run SDK integration tests (JavaExec, not JUnit)
./gradlew :sdk:runTests           # DirectorFileTest
./gradlew :sdk:runDcrTests        # DcrFileTest (optional: -Pfile=path/to/file.dcr)
./gradlew :sdk:runFeatureTests    # SdkFeatureTest

VM Tests (vm/src/test/)

Class Description
LingoVMTest VM execution, stack operations, control flow (21 tests)
OpcodeTest Opcode encoding/decoding and argument handling (51 tests)
DatumTest Datum type conversions and equality (11 tests)
ScriptInstanceTest Script instance lifecycle, properties, list operations (48 tests)
# Run VM tests via JavaExec
./gradlew :vm:runVmTests

Architecture

Modules

Module Description
sdk Core library for parsing Director/Shockwave files
vm Lingo bytecode virtual machine
player-core Platform-independent playback engine (score, events, rendering data)
player-swing Desktop player with Swing UI and debugger
player-wasm Browser player compiled to WebAssembly via TeaVM
cast-extractor GUI tool for extracting assets from Director files
SDK packages
  • com.libreshockwave - Main DirectorFile class
  • com.libreshockwave.chunks - Chunk type parsers (CASt, Lscr, BITD, STXT, etc.)
  • com.libreshockwave.bitmap - Bitmap decoding and palette handling
  • com.libreshockwave.audio - Sound conversion utilities
  • com.libreshockwave.lingo - Opcode definitions and decompiler
  • com.libreshockwave.io - Binary readers/writers
  • com.libreshockwave.format - File format utilities (Afterburner, chunk types)
  • com.libreshockwave.cast - Cast member type definitions
  • com.libreshockwave.font - PFR1 font parser, TTF converter, bitmap font rasterizer

References

This implementation draws from:

Licence

This project uses a split licence model:

See the LICENSE file in each module directory for full terms.

About

An open-source SDK, decompiler and web player for Adobe/Macromedia Shockwave

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages