Quackster/LibreShockwave

GitHub: Quackster/LibreShockwave

一个用于解析、反编译和播放 Adobe/Macromedia Shockwave 文件的 Java SDK,支持资产提取、Lingo 字节码反编译以及浏览器端 WebAssembly 播放器。

Stars: 18 | Forks: 1

# LibreShockwave SDK 一个用于解析 Macromedia/Adobe Director 和 Shockwave 文件(.dir、.dxr、.dcr、.cct、.cst)的 Java 库。 ## 环境要求 - Java 21 或更高版本 ## 构建 ``` ./gradlew build ``` ## 支持的格式 - RIFX 容器(大端序和小端序) - Afterburner 压缩文件(.dcr、.cct) - Director 版本 4 至 12 ## 功能特性 ### 读取 - Cast 成员(位图、文本、脚本、声音、形状、调色板、字体) - Lingo 字节码及符号解析 - Score/时间轴数据(帧、通道、标签、行为间隔) - 文件元数据(舞台尺寸、节奏、版本) ### 资产提取 - 位图:1/2/4/8/16/32 位深度,调色板支持,PNG 导出 - 文本:通过 STXT 块处理 Field(类型 3)和 Text(类型 12)cast 成员 - 声音:PCM 转 WAV,MP3 提取,IMA ADPCM 解码 - 调色板:内置 Director 调色板和自定义 CLUT 块 - 字体:从 XMED 块中提取 PFR1(Portable Font Resource),导出为 TrueType(.ttf) ### 写入 - 保存为未压缩的 RIFX 格式 - 移除受保护文件的保护 - 反编译 Lingo 源码并将其嵌入 cast 成员 ## Player 与 Lingo VM LibreShockwave 包含一个 Lingo 字节码虚拟机和播放器,可以加载并运行 Director 电影。该 VM 执行编译后的 Lingo 脚本,处理 score 播放、sprite 渲染以及外部 cast 加载——让 `.dcr` 和 `.dir` 文件重获新生。 **[试用在线演示 →](https://libre.oldskooler.org/)** — Web 播放器的每夜版构建已部署并可供使用。加载任意 `.dcr` 或 `.dir` 文件即可在浏览器中测试。 播放器提供两种形式: - **桌面版**(`player-swing`)— 基于 Swing 的 UI,集成 Lingo 调试器 - **Web 版**(`player-wasm`)— 通过 TeaVM 编译为 WebAssembly,可在任何现代浏览器中运行 所有播放器功能通过 `player-core` 模块与 SDK 和 VM 解耦,该模块提供平台无关的播放逻辑(score 遍历、事件分发、sprite 管理、位图解码)。 ![java_m4YLpAnayh](https://static.pigsec.cn/wp-content/uploads/repos/2026/03/c8352d0984225903.gif) ## 作为库使用 player-core `player-core` 模块提供与平台无关且无 UI 依赖的播放逻辑。使用它来构建自定义播放器(JavaFX、无头渲染器、服务器端处理器等)。 ### 依赖 ``` implementation project(':player-core') // transitively includes :vm and :sdk ``` ### 最简示例 ``` 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(); ``` 每次调用 `tick()` 会推进一帧,并在电影仍在播放时返回 `true`。`renderFrame()` 使用纯软件渲染将所有 sprite(位图、文本、形状)连同墨水效果合成为一张图像——无需依赖 AWT。
自定义网络 对于没有 `java.net.http` 的环境(例如 WASM、Android),向构造函数传递一个 `NetProvider`: ``` 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. */ } }); ```
外部参数 Shockwave 电影会从嵌入的 HTML 中读取 `` 标签。在调用 `play()` 之前传递这些参数: ``` player.setExternalParams(Map.of( "sw1", "external.variables.txt=http://example.com/vars.txt", "sw2", "connection.info.host=127.0.0.1" )); ```
事件监听器 ``` // 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"); }); ```
错误处理 ``` // 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() + "]"); } }); ``` 你也可以在执行期间的任何时刻(例如从 TraceListener 或断点处)获取调用堆栈: ``` // Get the live Lingo call stack (empty list when no handlers are executing) List stack = player.getLingoCallStack(); // Or as a formatted string String formatted = player.formatLingoCallStack(); ```
调试播放 调试播放控制 `put` 输出、错误调用堆栈和诊断日志。默认**开启**。 ``` // Disable debug output (suppresses put/error logging to stderr) DebugConfig.setDebugPlaybackEnabled(false); // Re-enable DebugConfig.setDebugPlaybackEnabled(true); ``` 对于字节码级别的调试(断点、单步执行、监视表达式),请使用桌面播放器的内置调试器或附加一个 `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 ```
生命周期 | 方法 | 描述 | |--------|-------------| | `play()` | 准备电影并开始播放 | | `tick()` | 推进一帧;当电影停止时返回 `false` | | `pause()` | 暂停播放(保持状态) | | `resume()` | 暂停后恢复播放 | | `stop()` | 停止播放并重置到第 1 帧 | | `shutdown()` | 释放所有资源(线程池、缓存) |
## 截图 ### Cast Extractor 一个用于浏览和提取 Director 文件中资产的 GUI 工具(可在 releases 页面获取)。 Cast Extractor ## 用法 ### 加载文件 ``` 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); ```
访问元数据 ``` 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) ```
遍历 Cast 成员 ``` 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 */ } } ```
提取位图 ``` 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")); }); } ```
提取文本 ``` 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; } } } ```
提取声音 ``` 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; } } } ```
提取字体(PFR1 → TTF) Director 文件可以将字体作为 PFR1(Portable Font Resource)数据嵌入到附加在 OLE 类型 cast 成员上的 XMED 块中。LibreShockwave 可以解析这些数据并将其转换为标准的 TrueType(.ttf)文件。 ``` 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); } ``` 播放器在 cast 库加载时会自动检测 PFR1 字体,在内存中将其转换为 TTF,并进行注册以实现像素级完美的文本渲染。
访问脚本和字节码 ``` ScriptNamesChunk names = file.getScriptNames(); for (ScriptChunk script : file.getScripts()) { // Script-level declarations List globals = script.getGlobalNames(names); List 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(); } } } ```
聚合全局变量和属性 ``` // All unique globals across all scripts Set allGlobals = file.getAllGlobalNames(); // All unique properties across all scripts Set 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(); } ```
读取 Score 数据 ``` 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(); } } ```
访问原始块 ``` // 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(); }); ```
外部 Cast 文件 ``` for (String castPath : file.getExternalCastPaths()) { Path resolved = baseDir.resolve(castPath); if (Files.exists(resolved)) { DirectorFile castFile = DirectorFile.load(resolved); } } ```
保存文件 ``` // 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) `player-wasm` 模块使用 [TeaVM](https://teavm.org/) v0.13 的标准 WebAssembly 后端将播放器编译为浏览器可用版本。它生成一个 `.wasm` 文件以及一个可在所有现代浏览器中运行的 JavaScript 库。 WASM 是一个纯计算引擎,**零 `@Import` 注解** —— JS 拥有网络(`fetch`)、Canvas 渲染和动画循环的控制权。所有 WASM 执行都在 **Web Worker** 中运行,因此缓慢的 Lingo 脚本永远不会阻塞主线程。 ### 构建 ``` ./gradlew :player-wasm:generateWasm ``` 这将把 Java 播放器编译为 WebAssembly,并将所有文件(WASM 二进制、JS 运行时、HTML、CSS)组合到 `player-wasm/build/dist/` 目录下。 ### 本地运行 ``` ./gradlew :player-wasm:generateWasm npx serve player-wasm/build/dist # 打开 http://localhost:3000 ``` ### 部署 将 `player-wasm/build/dist/` 的内容复制到你的 Web 服务器。其中包含的 `index.html` 是一个现成的播放器页面,具有文件选择器、URL 输入框、播放控制栏和参数编辑器。 ### 嵌入到任意网页 引入 `shockwave-lib.js` 并添加一个 ``。就这样。 ``` ``` 以下文件必须与脚本位于同一目录下: | 文件 | 用途 | |------|---------| | `shockwave-lib.js` | 播放器库(你唯一需要的 `