From f087a7ece4f63ec0d6bb2ba6fb4a926cd3a152a3 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 18:45:27 -0400 Subject: [PATCH 01/41] AnimateLibraryExporter: use lime.tools.AssetType for generated class templates (closes #21) --- src/swf/exporters/AnimateLibraryExporter.hx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 1a5e2ee99..e7fad6198 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -1301,7 +1301,7 @@ class AnimateLibraryExporter var template = new Template(templateData); var templateFile = new Asset("", Path.combine(Path.combine(targetPath, Path.directory(className.split(".").join("/"))), name + ".hx"), - cast AssetType.TEMPLATE); + lime.tools.AssetType.TEMPLATE); templateFile.embed = false; templateFile.data = template.execute(context); output.push(templateFile); From edbab7f22412544e7a212fae526a7fc060322673 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 18:45:27 -0400 Subject: [PATCH 02/41] Tools: write generated Flash/AIR classes to their lowercased package path, without a duplicate prefix (closes #7) --- scripts/Tools.hx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/Tools.hx b/scripts/Tools.hx index 2ab0dc76d..e0b8e2823 100644 --- a/scripts/Tools.hx +++ b/scripts/Tools.hx @@ -337,7 +337,7 @@ class Tools // } - var templateFile = new Asset("", Path.combine(targetPath, Path.directory(className.split(".").join("/"))) + "/" + prefix + name + ".hx", + var templateFile = new Asset("", Path.combine(Path.combine(targetPath, packageName.split(".").join("/")), name + ".hx"), AssetType.TEMPLATE); templateFile.data = template.execute(context); output.assets.push(templateFile); From 338fb9cf3aa635b43705db90838d6e5aea4d57ef Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 18:45:27 -0400 Subject: [PATCH 03/41] TagDefineFont2: fix parsing fonts without layout reading past the tag (closes #22) The no-layout branch always skipped 2 extra bytes, which only matches a CodeTableOffset written for fonts with no glyphs. Skip that offset only when there are no glyphs and the tag still has bytes for it. --- src/swf/tags/TagDefineFont2.hx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/swf/tags/TagDefineFont2.hx b/src/swf/tags/TagDefineFont2.hx index ec0a45e85..8a0a71acc 100644 --- a/src/swf/tags/TagDefineFont2.hx +++ b/src/swf/tags/TagDefineFont2.hx @@ -43,6 +43,7 @@ class TagDefineFont2 extends TagDefineFont implements IDefinitionTag override public function parse(data:SWFData, length:Int, version:Int, async:Bool = false):Void { + var start:Int = data.position; characterId = data.readUI16(); var flags:Int = data.readUI8(); hasLayout = ((flags & 0x80) != 0); @@ -82,6 +83,12 @@ class TagDefineFont2 extends TagDefineFont implements IDefinitionTag codeTable.push(wideCodes ? data.readUI16() : data.readUI8()); } } + else if (length - (data.position - start) > (hasLayout ? 8 : 0)) + { + // Flash may write a CodeTableOffset even when there are no glyphs. + // Without glyphs to kern, the layout that follows is 8 bytes. + data.skipBytes(wideOffsets ? 4 : 2); + } if (hasLayout) { ascent = data.readUI16(); @@ -103,7 +110,6 @@ class TagDefineFont2 extends TagDefineFont implements IDefinitionTag } else { - data.skipBytes(2); ascent = 0; descent = 0; leading = 0; From 86b05c9e0855e52136de475d43fd5308abf74a2f Mon Sep 17 00:00:00 2001 From: Asimetra Date: Thu, 30 Apr 2026 16:11:20 +0300 Subject: [PATCH 04/41] Keep clip-depth changes from reusing timeline instances (closes #50) --- src/swf/exporters/animate/AnimateTimeline.hx | 3 ++- src/swf/exporters/swflite/timeline/SymbolTimeline.hx | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index dbe2cca18..15cded6b3 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -372,7 +372,8 @@ class AnimateTimeline extends Timeline { if (activeInstance.displayObject != null && activeInstance.characterID == frameObject.symbol - && activeInstance.depth == frameObject.depth) + && activeInstance.depth == frameObject.depth + && activeInstance.clipDepth == frameObject.clipDepth) { // TODO: Fix duplicates in exporter instance = activeInstance; diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index ed1e8d294..2ba01ab26 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -210,7 +210,8 @@ class SymbolTimeline extends Timeline { if (activeInstance.displayObject != null && activeInstance.characterID == frameObject.symbol - && activeInstance.depth == frameObject.depth) + && activeInstance.depth == frameObject.depth + && activeInstance.clipDepth == frameObject.clipDepth) { // TODO: Fix duplicates in exporter instance = activeInstance; From 02b30660299f22cf1a5176afc282af00017b454a Mon Sep 17 00:00:00 2001 From: Asimetra Date: Wed, 29 Apr 2026 20:17:06 +0300 Subject: [PATCH 05/41] Add mask handling for timeline objects in AnimateTimeline and SymbolTimeline --- src/swf/exporters/animate/AnimateTimeline.hx | 16 ++++++++++++++++ .../exporters/swflite/timeline/SymbolTimeline.hx | 15 +++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index 15cded6b3..f54935501 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -243,6 +243,7 @@ class AnimateTimeline extends Timeline } child = targetChild; + maskApplied = false; for (mask in currentMasks) @@ -265,6 +266,11 @@ class AnimateTimeline extends Timeline } } + for (mask in currentMasks) + { + __markTimelineMask(mask.displayObject); + } + // TODO: How to tell if shapes are for a scale9Grid clip? if (__sprite.scale9Grid != null) { @@ -447,6 +453,16 @@ class AnimateTimeline extends Timeline return a.depth - b.depth; } + @:noCompletion private inline function __markTimelineMask(displayObject:DisplayObject):Void + { + // SWF clipDepth>0 placements are masks even before a child exists in their depth range. + if (!displayObject.__isMask) + { + displayObject.__isMask = true; + displayObject.__setRenderDirty(); + } + } + @:noCompletion private function __updateDisplayObject(displayObject:DisplayObject, frameObject:AnimateFrameObject, reset:Bool = false):Void { if (displayObject == null) return; diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index 2ba01ab26..4b539b90c 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -377,6 +377,11 @@ class SymbolTimeline extends Timeline } } + for (mask in currentMasks) + { + __markTimelineMask(mask.displayObject); + } + var child:DisplayObject; var i = currentInstances.length; var length = __movieClip.numChildren; @@ -420,6 +425,16 @@ class SymbolTimeline extends Timeline return a.depth - b.depth; } + @:noCompletion private inline function __markTimelineMask(displayObject:DisplayObject):Void + { + // SWF clipDepth>0 placements are masks even before a child exists in their depth range. + if (!displayObject.__isMask) + { + displayObject.__isMask = true; + displayObject.__setRenderDirty(); + } + } + @:noCompletion private function __updateDisplayObject(displayObject:DisplayObject, frameObject:FrameObject, reset:Bool = false):Void { if (displayObject == null) return; From 350fbae1d1e601c4544f7d2b0b344c90396144ef Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 18:45:55 -0400 Subject: [PATCH 06/41] Timelines: don't mark clipping characters as masks on flash target (closes #49) --- src/swf/exporters/animate/AnimateTimeline.hx | 5 ++++- src/swf/exporters/swflite/timeline/SymbolTimeline.hx | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index f54935501..5f0ab7c8e 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -243,7 +243,6 @@ class AnimateTimeline extends Timeline } child = targetChild; - maskApplied = false; for (mask in currentMasks) @@ -266,10 +265,12 @@ class AnimateTimeline extends Timeline } } + #if !flash for (mask in currentMasks) { __markTimelineMask(mask.displayObject); } + #end // TODO: How to tell if shapes are for a scale9Grid clip? if (__sprite.scale9Grid != null) @@ -453,6 +454,7 @@ class AnimateTimeline extends Timeline return a.depth - b.depth; } + #if !flash @:noCompletion private inline function __markTimelineMask(displayObject:DisplayObject):Void { // SWF clipDepth>0 placements are masks even before a child exists in their depth range. @@ -462,6 +464,7 @@ class AnimateTimeline extends Timeline displayObject.__setRenderDirty(); } } + #end @:noCompletion private function __updateDisplayObject(displayObject:DisplayObject, frameObject:AnimateFrameObject, reset:Bool = false):Void { diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index 4b539b90c..6690686fa 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -377,10 +377,12 @@ class SymbolTimeline extends Timeline } } + #if !flash for (mask in currentMasks) { __markTimelineMask(mask.displayObject); } + #end var child:DisplayObject; var i = currentInstances.length; @@ -425,6 +427,7 @@ class SymbolTimeline extends Timeline return a.depth - b.depth; } + #if !flash @:noCompletion private inline function __markTimelineMask(displayObject:DisplayObject):Void { // SWF clipDepth>0 placements are masks even before a child exists in their depth range. @@ -434,6 +437,7 @@ class SymbolTimeline extends Timeline displayObject.__setRenderDirty(); } } + #end @:noCompletion private function __updateDisplayObject(displayObject:DisplayObject, frameObject:FrameObject, reset:Bool = false):Void { From 567f265347f6538065afe39d5f2c51015b266015 Mon Sep 17 00:00:00 2001 From: m0rkeulv Date: Sun, 13 Sep 2026 18:09:28 +0200 Subject: [PATCH 07/41] Solve performance issues with SWF exporter (closes #59) --- src/swf/data/SWFShape.hx | 66 ++++++--- src/swf/exporters/AnimateLibraryExporter.hx | 152 ++++++++++---------- 2 files changed, 117 insertions(+), 101 deletions(-) diff --git a/src/swf/data/SWFShape.hx b/src/swf/data/SWFShape.hx index fc93214a3..8be5c9e63 100644 --- a/src/swf/data/SWFShape.hx +++ b/src/swf/data/SWFShape.hx @@ -18,6 +18,7 @@ import openfl.display.LineScaleMode; import openfl.geom.Matrix; import openfl.geom.Point; import openfl.errors.Error; +import haxe.ds.ObjectMap; class SWFShape { @@ -655,37 +656,56 @@ class SWFShape var subPath:Array = edgeMap.get(styleIdx); if (subPath != null && subPath.length > 0) { - var idx:Int; - var prevEdge:IEdge = null; + var n = subPath.length; + var nextFree = new Array(); + var prevFree = new Array(); + var edgeIndexMap = new ObjectMap(); + + for (i in 0...n) + { + nextFree.push(i + 1); + prevFree.push(i - 1); + edgeIndexMap.set(subPath[i], i); + } + var tmpPath:Array = new Array(); createCoordMap(subPath); - while (subPath.length > 0) + + var head = 0; + var idx = 0; + var remaining = n; + var prevEdge:IEdge = null; + + while (remaining > 0) { - idx = 0; - while (idx < subPath.length) + var cand = subPath[idx]; + if (prevEdge == null || prevEdge.to.equals(cand.from)) { - if (prevEdge == null || prevEdge.to.equals(subPath[idx].from)) + remaining--; + tmpPath.push(cand); + removeEdgeFromCoordMap(cand); + prevEdge = cand; + + var next = nextFree[idx]; + var prev = prevFree[idx]; + + if (prev >= 0) nextFree[prev] = next; + else head = next; + + if (next < n) prevFree[next] = prev; + idx = next < n ? next : head; + } + else + { + var edge = findNextEdgeInCoordMap(prevEdge); + if (edge != null) { - var edge:IEdge = subPath.splice(idx, 1)[0]; - tmpPath.push(edge); - removeEdgeFromCoordMap(edge); - prevEdge = edge; + idx = edgeIndexMap.get(edge); } else { - var edge = findNextEdgeInCoordMap(prevEdge); - if (edge != null) - { - for (i in 0...subPath.length) - { - if (subPath[i] == edge) idx = i; - } - } - else - { - idx = 0; - prevEdge = null; - } + idx = head; + prevEdge = null; } } } diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index e7fad6198..4aa8a8865 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -600,62 +600,60 @@ class AnimateLibraryExporter } else { - commands = commands.concat([ - SWFShapeCommandType.LINE_STYLE, - thickness, - color, - alpha, - pixelHinting, - scaleMode, - startCaps, - joints, - miterLimit - ]); + commands.push(SWFShapeCommandType.LINE_STYLE); + commands.push(thickness); + commands.push(color); + commands.push(alpha); + commands.push(pixelHinting); + commands.push(scaleMode); + commands.push(startCaps); + commands.push(joints); + commands.push(miterLimit); } case BeginFill(color, alpha): - commands = commands.concat([SWFShapeCommandType.BEGIN_FILL, color, alpha]); + commands.push(SWFShapeCommandType.BEGIN_FILL); + commands.push(color); + commands.push(alpha); case BeginGradientFill(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): - commands = commands.concat([ - SWFShapeCommandType.BEGIN_GRADIENT_FILL, - type, - colors, - alphas, - ratios, - serializeMatrix(matrix), - spreadMethod, - interpolationMethod, - focalPointRatio - ]); + commands.push(SWFShapeCommandType.BEGIN_GRADIENT_FILL); + commands.push(type); + commands.push(colors); + commands.push(alphas); + commands.push(ratios); + commands.push(serializeMatrix(matrix)); + commands.push(spreadMethod); + commands.push(interpolationMethod); + commands.push(focalPointRatio); case BeginBitmapFill(bitmapID, matrix, repeat, smooth): - commands = commands.concat([ - SWFShapeCommandType.BEGIN_BITMAP_FILL, - bitmapID, - serializeMatrix(matrix), - repeat, - smooth - ]); + commands.push(SWFShapeCommandType.BEGIN_BITMAP_FILL); + commands.push(bitmapID); + commands.push(serializeMatrix(matrix)); + commands.push(repeat); + commands.push(smooth); processTag(cast swfData.getCharacter(bitmapID)); case EndFill: commands.push(SWFShapeCommandType.END_FILL); case MoveTo(x, y): - commands = commands.concat([SWFShapeCommandType.MOVE_TO, twip(x), twip(y)]); + commands.push(SWFShapeCommandType.MOVE_TO); + commands.push(twip(x)); + commands.push(twip(y)); case LineTo(x, y): - commands = commands.concat([SWFShapeCommandType.LINE_TO, twip(x), twip(y)]); + commands.push(SWFShapeCommandType.LINE_TO); + commands.push(twip(x)); + commands.push(twip(y)); case CurveTo(controlX, controlY, anchorX, anchorY): - commands = commands.concat([ - SWFShapeCommandType.CURVE_TO, - twip(controlX), - twip(controlY), - twip(anchorX), - twip(anchorY) - ]); + commands.push(SWFShapeCommandType.CURVE_TO); + commands.push(twip(controlX)); + commands.push(twip(controlY)); + commands.push(twip(anchorX)); + commands.push(twip(anchorY)); default: } @@ -979,62 +977,60 @@ class AnimateLibraryExporter } else { - commands = commands.concat([ - SWFShapeCommandType.LINE_STYLE, - thickness, - color, - alpha, - pixelHinting, - scaleMode, - startCaps, - joints, - miterLimit - ]); + commands.push(SWFShapeCommandType.LINE_STYLE); + commands.push(thickness); + commands.push(color); + commands.push(alpha); + commands.push(pixelHinting); + commands.push(scaleMode); + commands.push(startCaps); + commands.push(joints); + commands.push(miterLimit); } case BeginFill(color, alpha): - commands = commands.concat([SWFShapeCommandType.BEGIN_FILL, color, alpha]); + commands.push(SWFShapeCommandType.BEGIN_FILL); + commands.push(color); + commands.push(alpha); case BeginGradientFill(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): - commands = commands.concat([ - SWFShapeCommandType.BEGIN_GRADIENT_FILL, - type, - colors, - alphas, - ratios, - serializeMatrix(matrix), - spreadMethod, - interpolationMethod, - focalPointRatio - ]); + commands.push(SWFShapeCommandType.BEGIN_GRADIENT_FILL); + commands.push(type); + commands.push(colors); + commands.push(alphas); + commands.push(ratios); + commands.push(serializeMatrix(matrix)); + commands.push(spreadMethod); + commands.push(interpolationMethod); + commands.push(focalPointRatio); case BeginBitmapFill(bitmapID, matrix, repeat, smooth): - commands = commands.concat([ - SWFShapeCommandType.BEGIN_BITMAP_FILL, - bitmapID, - serializeMatrix(matrix), - repeat, - smooth - ]); + commands.push(SWFShapeCommandType.BEGIN_BITMAP_FILL); + commands.push(bitmapID); + commands.push(serializeMatrix(matrix)); + commands.push(repeat); + commands.push(smooth); processTag(cast swfData.getCharacter(bitmapID)); case EndFill: commands.push(SWFShapeCommandType.END_FILL); case MoveTo(x, y): - commands = commands.concat([SWFShapeCommandType.MOVE_TO, twip(x), twip(y)]); + commands.push(SWFShapeCommandType.MOVE_TO); + commands.push(twip(x)); + commands.push(twip(y)); case LineTo(x, y): - commands = commands.concat([SWFShapeCommandType.LINE_TO, twip(x), twip(y)]); + commands.push(SWFShapeCommandType.LINE_TO); + commands.push(twip(x)); + commands.push(twip(y)); case CurveTo(controlX, controlY, anchorX, anchorY): - commands = commands.concat([ - SWFShapeCommandType.CURVE_TO, - twip(controlX), - twip(controlY), - twip(anchorX), - twip(anchorY) - ]); + commands.push(SWFShapeCommandType.CURVE_TO); + commands.push(twip(controlX)); + commands.push(twip(controlY)); + commands.push(twip(anchorX)); + commands.push(twip(anchorY)); default: } From eb255c29dc9384cf0f5a27ede3b604007eed1c63 Mon Sep 17 00:00:00 2001 From: Dimitri Tugo Date: Fri, 17 Oct 2025 16:05:24 +0200 Subject: [PATCH 08/41] memory leak fix (closes #36) --- src/swf/exporters/animate/AnimateLibrary.hx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index 9aed6edf7..83db3ae4c 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -66,14 +66,12 @@ import openfl.filters.GlowFilter; private var rootPath:String; private var symbols:Map; private var symbolsByClassName:Map; - private var uuid:String; public function new(id:String, uuid:String = null) { super(); this.id = id; - this.uuid = uuid; instanceID = uuid != null ? uuid : id; @@ -86,7 +84,7 @@ import openfl.filters.GlowFilter; rootPath = ""; #end - instances.set(uuid, this); + instances.set(instanceID, this); // Hack to include filter classes, macro.include is not working properly @@ -447,7 +445,7 @@ import openfl.filters.GlowFilter; #if lime public override function unload():Void { - instances.remove(uuid); + instances.remove(instanceID); if (symbols == null) return; // if (swf == null) return; From 45ab96fe04053a8ec9dd4dbc69f15eb186ffe93e Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Sat, 30 May 2026 01:17:49 +0200 Subject: [PATCH 09/41] Preserve lineGradientStyle in SWF shape exports (cherry picked from commit 954ed5ff38303f3a8a9c87ad83a4b4c2ccff8076) --- src/swf/exporters/AnimateLibraryExporter.hx | 27 +++++++++++++++++++ src/swf/exporters/ShapeCommandExporter.hx | 6 +++++ src/swf/exporters/animate/AnimateLibrary.hx | 9 +++++++ .../exporters/animate/AnimateShapeCommand.hx | 2 ++ .../exporters/animate/AnimateShapeSymbol.hx | 4 +++ src/swf/exporters/core/ShapeCommand.hx | 2 ++ src/swf/exporters/swflite/ShapeSymbol.hx | 3 +++ 7 files changed, 53 insertions(+) diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 4aa8a8865..280779482 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -611,6 +611,19 @@ class AnimateLibraryExporter commands.push(miterLimit); } + case LineGradientStyle(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + commands = commands.concat([ + SWFShapeCommandType.LINE_GRADIENT_STYLE, + type, + colors, + alphas, + ratios, + serializeMatrix(matrix), + spreadMethod, + interpolationMethod, + focalPointRatio + ]); + case BeginFill(color, alpha): commands.push(SWFShapeCommandType.BEGIN_FILL); commands.push(color); @@ -988,6 +1001,19 @@ class AnimateLibraryExporter commands.push(miterLimit); } + case LineGradientStyle(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + commands = commands.concat([ + SWFShapeCommandType.LINE_GRADIENT_STYLE, + type, + colors, + alphas, + ratios, + serializeMatrix(matrix), + spreadMethod, + interpolationMethod, + focalPointRatio + ]); + case BeginFill(color, alpha): commands.push(SWFShapeCommandType.BEGIN_FILL); commands.push(color); @@ -1537,6 +1563,7 @@ private #if (haxe_ver >= 4.0) enum #end abstract SWFShapeCommandType(Int) from I public var LINE_STYLE = 6; public var LINE_TO = 7; public var MOVE_TO = 8; + public var LINE_GRADIENT_STYLE = 9; } #if (haxe_ver < 4.0) @:enum #end diff --git a/src/swf/exporters/ShapeCommandExporter.hx b/src/swf/exporters/ShapeCommandExporter.hx index ff5129de4..5dbe15f25 100644 --- a/src/swf/exporters/ShapeCommandExporter.hx +++ b/src/swf/exporters/ShapeCommandExporter.hx @@ -69,6 +69,12 @@ class ShapeCommandExporter extends DefaultShapeExporter commands.push(LineStyle(thickness, color, alpha, pixelHinting, scaleMode, startCaps, /*endCaps,*/ joints, miterLimit)); } + override public function lineGradientStyle(type:GradientType, colors:Array, alphas:Array, ratios:Array, matrix:Matrix = null, + spreadMethod:SpreadMethod = null, interpolationMethod:InterpolationMethod = null, focalPointRatio:Float = 0):Void + { + commands.push(LineGradientStyle(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio)); + } + override public function moveTo(x:Float, y:Float):Void { commands.push(MoveTo(x, y)); diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index 83db3ae4c..a9bce9c30 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -660,6 +660,10 @@ import openfl.filters.GlowFilter; commands.push(LineStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], data[i + 5], data[i + 6], data[i + 7], data[i + 8])); i += 9; + case LINE_GRADIENT_STYLE: + commands.push(LineGradientStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], __parseMatrix(data[i + 5]), data[i + 6], + data[i + 7], data[i + 8])); + i += 9; case LINE_TO: commands.push(LineTo(__pixel(data[i + 1]), __pixel(data[i + 2]))); i += 3; @@ -717,6 +721,10 @@ import openfl.filters.GlowFilter; case LINE_STYLE: commands.push(LineStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], data[i + 5], data[i + 6], data[i + 7], data[i + 8])); i += 9; + case LINE_GRADIENT_STYLE: + commands.push(LineGradientStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], __parseMatrix(data[i + 5]), data[i + 6], + data[i + 7], data[i + 8])); + i += 9; case LINE_TO: commands.push(LineTo(__pixel(data[i + 1]), __pixel(data[i + 2]))); i += 3; @@ -817,6 +825,7 @@ import openfl.filters.GlowFilter; public var LINE_STYLE = 6; public var LINE_TO = 7; public var MOVE_TO = 8; + public var LINE_GRADIENT_STYLE = 9; } #if (haxe_ver >= 4.0) enum #else @:enum #end abstract SWFSymbolType(Int) from Int to Int diff --git a/src/swf/exporters/animate/AnimateShapeCommand.hx b/src/swf/exporters/animate/AnimateShapeCommand.hx index 7a708b0c3..d768c6bcc 100644 --- a/src/swf/exporters/animate/AnimateShapeCommand.hx +++ b/src/swf/exporters/animate/AnimateShapeCommand.hx @@ -19,6 +19,8 @@ enum AnimateShapeCommand EndFill; LineStyle(thickness:Null, color:Null, alpha:Null, pixelHinting:Null, scaleMode:Null /*LineScaleMode*/, caps:Null /*CapsStyle*/, joints:Null /*JointStyle*/, miterLimit:Null); + LineGradientStyle(fillType:Null /*GradientType*/, colors:Array, alphas:Array, ratios:Array, matrix:Matrix, + spreadMethod:Null /*SpreadMethod*/, interpolationMethod:Null /*InterpolationMethod*/, focalPointRatio:Float); LineTo(x:Float, y:Float); MoveTo(x:Float, y:Float); } diff --git a/src/swf/exporters/animate/AnimateShapeSymbol.hx b/src/swf/exporters/animate/AnimateShapeSymbol.hx index 7f2d1c959..902aa975e 100644 --- a/src/swf/exporters/animate/AnimateShapeSymbol.hx +++ b/src/swf/exporters/animate/AnimateShapeSymbol.hx @@ -83,6 +83,10 @@ class AnimateShapeSymbol extends AnimateSymbol graphics.lineStyle(); } + case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + graphics.lineGradientStyle(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), + InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + case LineTo(x, y): graphics.lineTo(x, y); diff --git a/src/swf/exporters/core/ShapeCommand.hx b/src/swf/exporters/core/ShapeCommand.hx index 923e3f81f..9c8c4d8f7 100644 --- a/src/swf/exporters/core/ShapeCommand.hx +++ b/src/swf/exporters/core/ShapeCommand.hx @@ -25,6 +25,8 @@ enum ShapeCommand EndFill; LineStyle(thickness:Null, color:Null, alpha:Null, pixelHinting:Null, scaleMode:LineScaleMode, caps:CapsStyle, joints:JointStyle, miterLimit:Null); + LineGradientStyle(fillType:GradientType, colors:Array, alphas:Array, ratios:Array, matrix:Matrix, spreadMethod:SpreadMethod, + interpolationMethod:InterpolationMethod, focalPointRatio:Float); LineTo(x:Float, y:Float); MoveTo(x:Float, y:Float); } diff --git a/src/swf/exporters/swflite/ShapeSymbol.hx b/src/swf/exporters/swflite/ShapeSymbol.hx index 706423294..55421ee3d 100644 --- a/src/swf/exporters/swflite/ShapeSymbol.hx +++ b/src/swf/exporters/swflite/ShapeSymbol.hx @@ -83,6 +83,9 @@ class ShapeSymbol extends SWFSymbol graphics.lineStyle(); } + case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); + case LineTo(x, y): graphics.lineTo(x, y); From ba1490875f31fc89e2b3d8689476b9582d3ca414 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Sat, 30 May 2026 01:44:55 +0200 Subject: [PATCH 10/41] Handle line gradients in SWF runtime shapes --- src/swf/runtime/MorphShape.hx | 3 +++ src/swf/runtime/Shape.hx | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/swf/runtime/MorphShape.hx b/src/swf/runtime/MorphShape.hx index aab8d50d8..ee93f7e85 100644 --- a/src/swf/runtime/MorphShape.hx +++ b/src/swf/runtime/MorphShape.hx @@ -69,6 +69,9 @@ class MorphShape extends openfl.display.Shape graphics.lineStyle(); } + case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); + case LineTo(x, y): graphics.lineTo(x, y); diff --git a/src/swf/runtime/Shape.hx b/src/swf/runtime/Shape.hx index a8afa806e..bf1770bcd 100644 --- a/src/swf/runtime/Shape.hx +++ b/src/swf/runtime/Shape.hx @@ -49,6 +49,9 @@ class Shape extends openfl.display.Shape graphics.lineStyle(); } + case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); + case LineTo(x, y): graphics.lineTo(x, y); From 2ade7d349f9af3b2a36fbdddf2805594828d4184 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Sat, 30 May 2026 01:51:23 +0200 Subject: [PATCH 11/41] Fix Flash color typing for line gradients --- src/swf/exporters/animate/AnimateShapeSymbol.hx | 3 +++ src/swf/exporters/swflite/ShapeSymbol.hx | 3 +++ src/swf/runtime/MorphShape.hx | 3 +++ src/swf/runtime/Shape.hx | 3 +++ 4 files changed, 12 insertions(+) diff --git a/src/swf/exporters/animate/AnimateShapeSymbol.hx b/src/swf/exporters/animate/AnimateShapeSymbol.hx index 902aa975e..3810522c8 100644 --- a/src/swf/exporters/animate/AnimateShapeSymbol.hx +++ b/src/swf/exporters/animate/AnimateShapeSymbol.hx @@ -84,6 +84,9 @@ class AnimateShapeSymbol extends AnimateSymbol } case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end graphics.lineGradientStyle(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); diff --git a/src/swf/exporters/swflite/ShapeSymbol.hx b/src/swf/exporters/swflite/ShapeSymbol.hx index 55421ee3d..1cedcdb8b 100644 --- a/src/swf/exporters/swflite/ShapeSymbol.hx +++ b/src/swf/exporters/swflite/ShapeSymbol.hx @@ -84,6 +84,9 @@ class ShapeSymbol extends SWFSymbol } case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); case LineTo(x, y): diff --git a/src/swf/runtime/MorphShape.hx b/src/swf/runtime/MorphShape.hx index ee93f7e85..8cd4c0eec 100644 --- a/src/swf/runtime/MorphShape.hx +++ b/src/swf/runtime/MorphShape.hx @@ -70,6 +70,9 @@ class MorphShape extends openfl.display.Shape } case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); case LineTo(x, y): diff --git a/src/swf/runtime/Shape.hx b/src/swf/runtime/Shape.hx index bf1770bcd..680f9a5a1 100644 --- a/src/swf/runtime/Shape.hx +++ b/src/swf/runtime/Shape.hx @@ -50,6 +50,9 @@ class Shape extends openfl.display.Shape } case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end graphics.lineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio); case LineTo(x, y): From 0fb74882f5baa0d6533e88de4a152de4c2d7b705 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 19:07:16 -0400 Subject: [PATCH 12/41] AnimateLibraryExporter: push line gradient commands like other shape commands (closes #54) --- src/swf/exporters/AnimateLibraryExporter.hx | 40 ++++++++++----------- 1 file changed, 18 insertions(+), 22 deletions(-) diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 280779482..2be861e22 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -612,17 +612,15 @@ class AnimateLibraryExporter } case LineGradientStyle(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): - commands = commands.concat([ - SWFShapeCommandType.LINE_GRADIENT_STYLE, - type, - colors, - alphas, - ratios, - serializeMatrix(matrix), - spreadMethod, - interpolationMethod, - focalPointRatio - ]); + commands.push(SWFShapeCommandType.LINE_GRADIENT_STYLE); + commands.push(type); + commands.push(colors); + commands.push(alphas); + commands.push(ratios); + commands.push(serializeMatrix(matrix)); + commands.push(spreadMethod); + commands.push(interpolationMethod); + commands.push(focalPointRatio); case BeginFill(color, alpha): commands.push(SWFShapeCommandType.BEGIN_FILL); @@ -1002,17 +1000,15 @@ class AnimateLibraryExporter } case LineGradientStyle(type, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): - commands = commands.concat([ - SWFShapeCommandType.LINE_GRADIENT_STYLE, - type, - colors, - alphas, - ratios, - serializeMatrix(matrix), - spreadMethod, - interpolationMethod, - focalPointRatio - ]); + commands.push(SWFShapeCommandType.LINE_GRADIENT_STYLE); + commands.push(type); + commands.push(colors); + commands.push(alphas); + commands.push(ratios); + commands.push(serializeMatrix(matrix)); + commands.push(spreadMethod); + commands.push(interpolationMethod); + commands.push(focalPointRatio); case BeginFill(color, alpha): commands.push(SWFShapeCommandType.BEGIN_FILL); From 1a125218598fdee3ff5c10e12aa4263de9098200 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Sun, 26 Jul 2026 02:19:30 +0200 Subject: [PATCH 13/41] Fix Haxe 3 timeline condition parsing --- src/swf/exporters/animate/AnimateTimeline.hx | 6 +++++- src/swf/exporters/swflite/timeline/SymbolTimeline.hx | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index 5f0ab7c8e..7f61584be 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -249,8 +249,12 @@ class AnimateTimeline extends Timeline { if (targetDepth > mask.depth && targetDepth <= mask.clipDepth) { - #if (openfl >= "9.5.0" && !flash) + #if (openfl >= "9.5.0") + #if flash + child.mask = mask.displayObject; + #else child.clippingLayer = mask.displayObject; + #end #else child.mask = mask.displayObject; #end diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index 6690686fa..49bea1a67 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -361,8 +361,12 @@ class SymbolTimeline extends Timeline { if (targetDepth > mask.depth && targetDepth <= mask.clipDepth) { - #if (openfl >= "9.5.0" && !flash) + #if (openfl >= "9.5.0") + #if flash + child.mask = mask.displayObject; + #else child.clippingLayer = mask.displayObject; + #end #else child.mask = mask.displayObject; #end From e851255f8c916e97da0e06c6ef06bdf64c122ef4 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Sun, 26 Jul 2026 00:07:23 +0200 Subject: [PATCH 14/41] Optimize Animate shape command storage (closes #55) Merged with #54: compact commands also encode LINE_GRADIENT_STYLE, using the same layout as BEGIN_GRADIENT_FILL, and the regression test covers it. --- src/swf/exporters/animate/AnimateLibrary.hx | 47 +-- .../exporters/animate/AnimateShapeSymbol.hx | 332 ++++++++++++++++-- tests/src/TestMain.hx | 1 + tests/src/tests/AnimateShapeSymbolTest.hx | 58 +++ 4 files changed, 356 insertions(+), 82 deletions(-) create mode 100644 tests/src/tests/AnimateShapeSymbolTest.hx diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index a9bce9c30..d6c1b5bff 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -689,52 +689,7 @@ import openfl.filters.GlowFilter; { var symbol = new AnimateShapeSymbol(); symbol.id = data.id; - symbol.commands = []; - - var data:Array = data.commands; - var commands = symbol.commands; - var i = 0; - - while (i < data.length) - { - switch (data[i]) - { - case BEGIN_BITMAP_FILL: - commands.push(BeginBitmapFill(data[i + 1], __parseMatrix(data[i + 2]), data[i + 3], data[i + 4])); - i += 5; - case BEGIN_FILL: - commands.push(BeginFill(data[i + 1], data[i + 2])); - i += 3; - case BEGIN_GRADIENT_FILL: - commands.push(BeginGradientFill(data[i + 1], data[i + 2], data[i + 3], data[i + 4], __parseMatrix(data[i + 5]), data[i + 6], data[i + 7], - data[i + 8])); - i += 9; - case CLEAR_LINE_STYLE: - commands.push(LineStyle(null, null, null, null, null, null, null, null)); - i++; - case CURVE_TO: - commands.push(CurveTo(__pixel(data[i + 1]), __pixel(data[i + 2]), __pixel(data[i + 3]), __pixel(data[i + 4]))); - i += 5; - case END_FILL: - commands.push(EndFill); - i++; - case LINE_STYLE: - commands.push(LineStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], data[i + 5], data[i + 6], data[i + 7], data[i + 8])); - i += 9; - case LINE_GRADIENT_STYLE: - commands.push(LineGradientStyle(data[i + 1], data[i + 2], data[i + 3], data[i + 4], __parseMatrix(data[i + 5]), data[i + 6], - data[i + 7], data[i + 8])); - i += 9; - case LINE_TO: - commands.push(LineTo(__pixel(data[i + 1]), __pixel(data[i + 2]))); - i += 3; - case MOVE_TO: - commands.push(MoveTo(__pixel(data[i + 1]), __pixel(data[i + 2]))); - i += 3; - default: - i++; - } - } + symbol.__setCompactCommands(data.commands); return symbol; } diff --git a/src/swf/exporters/animate/AnimateShapeSymbol.hx b/src/swf/exporters/animate/AnimateShapeSymbol.hx index 3810522c8..8a897ab73 100644 --- a/src/swf/exporters/animate/AnimateShapeSymbol.hx +++ b/src/swf/exporters/animate/AnimateShapeSymbol.hx @@ -8,6 +8,7 @@ import openfl.display.JointStyle; import openfl.display.LineScaleMode; import openfl.display.Shape; import openfl.display.SpreadMethod; +import swf.exporters.animate.AnimateLibrary.SWFShapeCommandType; #if !openfl_debug @:fileXml('tags="haxe,release"') @@ -25,6 +26,8 @@ class AnimateShapeSymbol extends AnimateSymbol public var commands:Array; public var rendered:Shape; + private var compactCommands:Array; + public function new() { super(); @@ -41,38 +44,275 @@ class AnimateShapeSymbol extends AnimateSymbol return shape; } - for (command in commands) + if (compactCommands != null) + { + __renderCompactCommands(graphics, library); + } + else if (commands != null) { - switch (command) + for (command in commands) { - case BeginFill(color, alpha): - graphics.beginFill(color, alpha); + switch (command) + { + case BeginFill(color, alpha): + graphics.beginFill(color, alpha); - case BeginBitmapFill(bitmapID, matrix, repeat, smooth): - #if lime - var bitmapSymbol:AnimateBitmapSymbol = cast library.symbols.get(bitmapID); - var bitmap = library.getImage(bitmapSymbol.path); + case BeginBitmapFill(bitmapID, matrix, repeat, smooth): + __beginBitmapFill(graphics, library, bitmapID, matrix, repeat, smooth); - if (bitmap != null) - { - graphics.beginBitmapFill(BitmapData.fromImage(bitmap), matrix, repeat, smooth); - } - #end + case BeginGradientFill(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end + graphics.beginGradientFill(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), + InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + + case CurveTo(controlX, controlY, anchorX, anchorY): + graphics.curveTo(controlX, controlY, anchorX, anchorY); + + case EndFill: + graphics.endFill(); + + case LineStyle(thickness, color, alpha, pixelHinting, scaleMode, caps, joints, miterLimit): + if (thickness != null) + { + graphics.lineStyle(thickness, color, alpha, pixelHinting, LineScaleMode.fromInt(scaleMode), CapsStyle.fromInt(caps), + JointStyle.fromInt(joints), miterLimit); + } + else + { + graphics.lineStyle(); + } + + case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): + #if flash + var colors:Array = cast colors; + #end + graphics.lineGradientStyle(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), + InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + + case LineTo(x, y): + graphics.lineTo(x, y); + + case MoveTo(x, y): + graphics.moveTo(x, y); + } + } + } + + commands = null; + compactCommands = null; + rendered = new Shape(); + rendered.graphics.copyFrom(shape.graphics); + + return shape; + } + + private static function __beginBitmapFill(graphics:openfl.display.Graphics, library:AnimateLibrary, bitmapID:Int, matrix:openfl.geom.Matrix, repeat:Bool, + smooth:Bool):Void + { + #if lime + var bitmapSymbol:AnimateBitmapSymbol = cast library.symbols.get(bitmapID); + var bitmap = library.getImage(bitmapSymbol.path); + + if (bitmap != null) + { + graphics.beginBitmapFill(BitmapData.fromImage(bitmap), matrix, repeat, smooth); + } + #end + } + + @:allow(swf.exporters.animate.AnimateLibrary) + private function __setCompactCommands(data:Array):Void + { + compactCommands = []; + var i = 0; + + while (i < data.length) + { + var type:SWFShapeCommandType = data[i]; + compactCommands.push(cast type); + + switch (type) + { + case BEGIN_BITMAP_FILL: + compactCommands.push(data[i + 1]); + __encodeMatrix(compactCommands, data[i + 2]); + compactCommands.push(data[i + 3] ? 1 : 0); + compactCommands.push(data[i + 4] ? 1 : 0); + i += 5; + + case BEGIN_FILL: + compactCommands.push(data[i + 1]); + compactCommands.push(data[i + 2]); + i += 3; + + case BEGIN_GRADIENT_FILL, LINE_GRADIENT_STYLE: + __encodeNullableNumber(compactCommands, data[i + 1]); + __encodeArray(compactCommands, data[i + 2]); + __encodeArray(compactCommands, data[i + 3]); + __encodeArray(compactCommands, data[i + 4]); + __encodeMatrix(compactCommands, data[i + 5]); + __encodeNullableNumber(compactCommands, data[i + 6]); + __encodeNullableNumber(compactCommands, data[i + 7]); + compactCommands.push(data[i + 8]); + i += 9; + + case CLEAR_LINE_STYLE: + i++; + + case CURVE_TO: + compactCommands.push(data[i + 1]); + compactCommands.push(data[i + 2]); + compactCommands.push(data[i + 3]); + compactCommands.push(data[i + 4]); + i += 5; + + case END_FILL: + i++; + + case LINE_STYLE: + __encodeNullableNumber(compactCommands, data[i + 1]); + __encodeNullableNumber(compactCommands, data[i + 2]); + __encodeNullableNumber(compactCommands, data[i + 3]); + __encodeNullableBool(compactCommands, data[i + 4]); + __encodeNullableNumber(compactCommands, data[i + 5]); + __encodeNullableNumber(compactCommands, data[i + 6]); + __encodeNullableNumber(compactCommands, data[i + 7]); + __encodeNullableNumber(compactCommands, data[i + 8]); + i += 9; + + case LINE_TO, MOVE_TO: + compactCommands.push(data[i + 1]); + compactCommands.push(data[i + 2]); + i += 3; + + default: + i++; + } + } + } + + private static function __encodeArray(output:Array, values:Array):Void + { + if (values == null) + { + output.push(-1); + return; + } + + output.push(values.length); + for (value in values) + { + output.push(value); + } + } + + private static function __encodeMatrix(output:Array, values:Array):Void + { + if (values == null) + { + output.push(0); + return; + } + + output.push(1); + for (i in 0...6) + { + output.push(values[i]); + } + } + + private static function __encodeNullableBool(output:Array, value:Dynamic):Void + { + output.push(value == null ? Math.NaN : (value ? 1 : 0)); + } + + private static function __encodeNullableNumber(output:Array, value:Dynamic):Void + { + output.push(value == null ? Math.NaN : value); + } + + private function __renderCompactCommands(graphics:openfl.display.Graphics, library:AnimateLibrary):Void + { + var data = compactCommands; + var position = 0; + + while (position < data.length) + { + var type:SWFShapeCommandType = cast Std.int(data[position++]); + + switch (type) + { + case BEGIN_BITMAP_FILL: + var bitmapID = Std.int(data[position++]); + var matrix = __decodeMatrix(data, position); + position += data[position] == 0 ? 1 : 7; + var repeat = data[position++] != 0; + var smooth = data[position++] != 0; + __beginBitmapFill(graphics, library, bitmapID, matrix, repeat, smooth); + + case BEGIN_FILL: + graphics.beginFill(Std.int(data[position++]), data[position++]); + + case BEGIN_GRADIENT_FILL, LINE_GRADIENT_STYLE: + var value = data[position++]; + var fillType:Null = Math.isNaN(value) ? null : Std.int(value); + var colors = __decodeIntArray(data, position); + position += 1 + (colors == null ? 0 : colors.length); + var alphas = __decodeFloatArray(data, position); + position += 1 + (alphas == null ? 0 : alphas.length); + var ratios = __decodeIntArray(data, position); + position += 1 + (ratios == null ? 0 : ratios.length); + var matrix = __decodeMatrix(data, position); + position += data[position] == 0 ? 1 : 7; + value = data[position++]; + var spreadMethod:Null = Math.isNaN(value) ? null : Std.int(value); + value = data[position++]; + var interpolationMethod:Null = Math.isNaN(value) ? null : Std.int(value); + var focalPointRatio = data[position++]; - case BeginGradientFill(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): #if flash - var colors:Array = cast colors; + var flashColors:Array = cast colors; #end - graphics.beginGradientFill(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), - InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + if (type == BEGIN_GRADIENT_FILL) + { + graphics.beginGradientFill(GradientType.fromInt(fillType), #if flash flashColors #else colors #end, alphas, ratios, matrix, + SpreadMethod.fromInt(spreadMethod), InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + } + else + { + graphics.lineGradientStyle(GradientType.fromInt(fillType), #if flash flashColors #else colors #end, alphas, ratios, matrix, + SpreadMethod.fromInt(spreadMethod), InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + } + + case CLEAR_LINE_STYLE: + graphics.lineStyle(); - case CurveTo(controlX, controlY, anchorX, anchorY): - graphics.curveTo(controlX, controlY, anchorX, anchorY); + case CURVE_TO: + graphics.curveTo(data[position++] / 20, data[position++] / 20, data[position++] / 20, data[position++] / 20); - case EndFill: + case END_FILL: graphics.endFill(); - case LineStyle(thickness, color, alpha, pixelHinting, scaleMode, caps, joints, miterLimit): + case LINE_STYLE: + var value = data[position++]; + var thickness:Null = Math.isNaN(value) ? null : value; + value = data[position++]; + var color:Null = Math.isNaN(value) ? null : Std.int(value); + value = data[position++]; + var alpha:Null = Math.isNaN(value) ? null : value; + value = data[position++]; + var pixelHinting:Null = Math.isNaN(value) ? null : value != 0; + value = data[position++]; + var scaleMode:Null = Math.isNaN(value) ? null : Std.int(value); + value = data[position++]; + var caps:Null = Math.isNaN(value) ? null : Std.int(value); + value = data[position++]; + var joints:Null = Math.isNaN(value) ? null : Std.int(value); + value = data[position++]; + var miterLimit:Null = Math.isNaN(value) ? null : value; + if (thickness != null) { graphics.lineStyle(thickness, color, alpha, pixelHinting, LineScaleMode.fromInt(scaleMode), CapsStyle.fromInt(caps), @@ -83,25 +323,45 @@ class AnimateShapeSymbol extends AnimateSymbol graphics.lineStyle(); } - case LineGradientStyle(fillType, colors, alphas, ratios, matrix, spreadMethod, interpolationMethod, focalPointRatio): - #if flash - var colors:Array = cast colors; - #end - graphics.lineGradientStyle(GradientType.fromInt(fillType), colors, alphas, ratios, matrix, SpreadMethod.fromInt(spreadMethod), - InterpolationMethod.fromInt(interpolationMethod), focalPointRatio); + case LINE_TO: + graphics.lineTo(data[position++] / 20, data[position++] / 20); - case LineTo(x, y): - graphics.lineTo(x, y); - - case MoveTo(x, y): - graphics.moveTo(x, y); + case MOVE_TO: + graphics.moveTo(data[position++] / 20, data[position++] / 20); } } + } - commands = null; - rendered = new Shape(); - rendered.graphics.copyFrom(shape.graphics); + private static function __decodeFloatArray(data:Array, position:Int):Array + { + var length = Std.int(data[position++]); + if (length < 0) return null; - return shape; + var result = []; + for (i in 0...length) + { + result.push(data[position + i]); + } + return result; + } + + private static function __decodeIntArray(data:Array, position:Int):Array + { + var length = Std.int(data[position++]); + if (length < 0) return null; + + var result = []; + for (i in 0...length) + { + result.push(Std.int(data[position + i])); + } + return result; + } + + private static function __decodeMatrix(data:Array, position:Int):openfl.geom.Matrix + { + if (data[position++] == 0) return null; + return new openfl.geom.Matrix(data[position], data[position + 1], data[position + 2], data[position + 3], data[position + 4] / 20, + data[position + 5] / 20); } } diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index 99caed6cf..7a1f16f02 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -9,6 +9,7 @@ class TestMain extends Sprite super(); var runner = new Runner(); + runner.addCase(new tests.AnimateShapeSymbolTest()); runner.addCase(new tests.ShapesTest()); Report.create(runner); diff --git a/tests/src/tests/AnimateShapeSymbolTest.hx b/tests/src/tests/AnimateShapeSymbolTest.hx new file mode 100644 index 000000000..7dd46ab47 --- /dev/null +++ b/tests/src/tests/AnimateShapeSymbolTest.hx @@ -0,0 +1,58 @@ +package tests; + +import openfl.geom.Matrix; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.animate.AnimateShapeCommand; +import swf.exporters.animate.AnimateShapeSymbol; +import utest.Assert; +import utest.Test; + +@:access(swf.exporters.animate.AnimateShapeSymbol) +class AnimateShapeSymbolTest extends Test +{ + public function testCompactCommandsRenderLikeLegacyCommands():Void + { + var colors = [0x123456, 0xABCDEF]; + var alphas = [1.0, 0.25]; + var ratios = [0, 255]; + var matrix = new Matrix(1, 0.25, -0.5, 2, 10, 20); + + var legacy = new AnimateShapeSymbol(); + legacy.commands = [ + BeginFill(0x102030, 0.75), + MoveTo(1, 2), + LineStyle(2.5, 0x405060, 0.5, true, 2, 1, 2, 3), + LineGradientStyle(1, colors, alphas, ratios, matrix, 2, 1, -0.5), + LineTo(3, 4), + CurveTo(5, 6, 7, 8), + LineStyle(null, null, null, null, null, null, null, null), + BeginGradientFill(0, colors, alphas, ratios, matrix, 1, 0, 0.25), + EndFill + ]; + + var compact = new AnimateShapeSymbol(); + var data:Array = [ + 1, 0x102030, 0.75, + 8, 20, 40, + 6, 2.5, 0x405060, 0.5, true, 2, 1, 2, 3, + 9, 1, colors, alphas, ratios, [1, 0.25, -0.5, 2, 200, 400], 2, 1, -0.5, + 7, 60, 80, + 4, 100, 120, 140, 160, + 3, + 2, 0, colors, alphas, ratios, [1, 0.25, -0.5, 2, 200, 400], 1, 0, 0.25, + 5 + ]; + compact.__setCompactCommands(data); + + var library = new AnimateLibrary("test", "test"); + var legacyShape = legacy.__createObject(library); + var compactShape = compact.__createObject(library); + var cachedCompactShape = compact.__createObject(library); + + Assert.same(legacyShape.graphics.readGraphicsData(false), compactShape.graphics.readGraphicsData(false)); + Assert.same(legacyShape.graphics.readGraphicsData(false), cachedCompactShape.graphics.readGraphicsData(false)); + Assert.same(legacyShape.getBounds(legacyShape), compactShape.getBounds(compactShape)); + Assert.isNull(compact.commands); + Assert.isNull(compact.compactCommands); + } +} From f2ef1c3a87ab344f77ec0abd1ad64c3046f3a049 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 20:23:22 -0400 Subject: [PATCH 15/41] AnimateShapeSymbolTest: compare raw draw commands, since readGraphicsData() skips line gradients --- tests/src/tests/AnimateShapeSymbolTest.hx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/src/tests/AnimateShapeSymbolTest.hx b/tests/src/tests/AnimateShapeSymbolTest.hx index 7dd46ab47..0a3a94e9c 100644 --- a/tests/src/tests/AnimateShapeSymbolTest.hx +++ b/tests/src/tests/AnimateShapeSymbolTest.hx @@ -8,6 +8,10 @@ import utest.Assert; import utest.Test; @:access(swf.exporters.animate.AnimateShapeSymbol) +#if !flash +@:access(openfl.display.Graphics) +@:access(openfl.display._internal.DrawCommandBuffer) +#end class AnimateShapeSymbolTest extends Test { public function testCompactCommandsRenderLikeLegacyCommands():Void @@ -52,6 +56,19 @@ class AnimateShapeSymbolTest extends Test Assert.same(legacyShape.graphics.readGraphicsData(false), compactShape.graphics.readGraphicsData(false)); Assert.same(legacyShape.graphics.readGraphicsData(false), cachedCompactShape.graphics.readGraphicsData(false)); Assert.same(legacyShape.getBounds(legacyShape), compactShape.getBounds(compactShape)); + + #if !flash + // readGraphicsData() does not report line gradients, so compare the raw draw commands too + var legacyCommands = legacyShape.graphics.__commands; + var compactCommands = compactShape.graphics.__commands; + Assert.same(legacyCommands.types, compactCommands.types); + Assert.same(legacyCommands.b, compactCommands.b); + Assert.same(legacyCommands.f, compactCommands.f); + Assert.same(legacyCommands.i, compactCommands.i); + Assert.same(legacyCommands.ii, compactCommands.ii); + Assert.same(legacyCommands.ff, compactCommands.ff); + Assert.same(legacyCommands.o, compactCommands.o); + #end Assert.isNull(compact.commands); Assert.isNull(compact.compactCommands); } From 2f9f3f6e68c9d88983d20273b43deb849f4a9abd Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 21:09:06 -0400 Subject: [PATCH 16/41] SymbolTimeline: fix compiling on flash target, where ColorTransform has no __isDefault --- src/swf/exporters/swflite/timeline/SymbolTimeline.hx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index 49bea1a67..cdb99d67a 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -461,7 +461,7 @@ class SymbolTimeline extends Timeline { displayObject.transform.colorTransform = frameObject.colorTransform; } - else if (reset && !displayObject.transform.colorTransform.__isDefault(true)) + else if (reset #if !flash && !displayObject.transform.colorTransform.__isDefault(true) #end) { displayObject.transform.colorTransform = new ColorTransform(); } From 6f05de2801506ac471abde6f7fcd4bf527ac174d Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Mon, 26 Oct 2026 21:21:23 -0400 Subject: [PATCH 17/41] Exporters: treat SymbolClass tag ID 0 as the root timeline SymbolClass uses character ID 0 for the document class, which is not always named *_fla.MainTimeline. Extracted from #44. --- src/swf/exporters/AnimateLibraryExporter.hx | 2 +- src/swf/exporters/SWFLiteExporter.hx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 2be861e22..58ee80721 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -1335,7 +1335,7 @@ class AnimateLibraryExporter { var data2 = processTag(cast swfData.getCharacter(symbol.tagId)); - if (data2 == null && ~/_fla\.MainTimeline$/.match(symbol.name)) + if (data2 == null && (symbol.tagId == 0 || ~/_fla\.MainTimeline$/.match(symbol.name))) { data2 = libraryData.root; } diff --git a/src/swf/exporters/SWFLiteExporter.hx b/src/swf/exporters/SWFLiteExporter.hx index a92f5a2ae..0d341b88c 100644 --- a/src/swf/exporters/SWFLiteExporter.hx +++ b/src/swf/exporters/SWFLiteExporter.hx @@ -860,7 +860,7 @@ class SWFLiteExporter var data2 = processTag(cast data.getCharacter(symbol.tagId)); - if (data2 == null && ~/_fla\.MainTimeline$/.match(symbol.name)) + if (data2 == null && (symbol.tagId == 0 || ~/_fla\.MainTimeline$/.match(symbol.name))) { data2 = swfLite.root; } From 91cb64ee83e4730cacc442daa524b5396d451605 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Thu, 5 Mar 2026 03:22:08 +0100 Subject: [PATCH 18/41] perf: cache instance fields in timelines (closes #41) Cache Type.getInstanceFields() results per class name in SymbolTimeline and AnimateTimeline to avoid repeated reflection during clip attachment/update. This reduces CPU and GC pressure when many symbols are instantiated. The PR's change to only clear filters on reset is left out, since it is a separate behavior change. --- src/swf/exporters/animate/AnimateTimeline.hx | 28 ++++++++++++++++++- .../swflite/timeline/SymbolTimeline.hx | 28 ++++++++++++++++++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index 7f61584be..4812e61a7 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -34,6 +34,8 @@ import hscript.Parser; @:access(openfl.geom.ColorTransform) class AnimateTimeline extends Timeline { + @:noCompletion private static var __instanceFieldsCacheByClassName:Map> = new Map(); + #if 0 // Suppress checkstyle warning private static var __unusedImport:Array> = [ @@ -432,7 +434,7 @@ class AnimateTimeline extends Timeline } #if !openfljs - __instanceFields = Type.getInstanceFields(Type.getClass(__sprite)); + __instanceFields = __getCachedInstanceFields(__sprite); #end enterFrame(1); @@ -571,6 +573,30 @@ class AnimateTimeline extends Timeline } } + @:noCompletion private static function __getCachedInstanceFields(displayObject:DisplayObject):Array + { + var clazz = Type.getClass(displayObject); + if (clazz == null) + { + return []; + } + + var className = Type.getClassName(clazz); + if (className == null) + { + return Type.getInstanceFields(clazz); + } + + var cached = __instanceFieldsCacheByClassName.get(className); + if (cached == null) + { + cached = Type.getInstanceFields(clazz); + __instanceFieldsCacheByClassName.set(className, cached); + } + + return cached; + } + #if hscript @:noCompletion private function __createScriptCallback(parser:Parser, scriptSource:String):MovieClip->Void { diff --git a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx index cdb99d67a..aab235259 100644 --- a/src/swf/exporters/swflite/timeline/SymbolTimeline.hx +++ b/src/swf/exporters/swflite/timeline/SymbolTimeline.hx @@ -39,6 +39,8 @@ import hscript.Parser; @:access(openfl.geom.ColorTransform) class SymbolTimeline extends Timeline { + @:noCompletion private static var __instanceFieldsCacheByClassName:Map> = new Map(); + #if openfljs @:noCompletion private static var __useParentFPS:Bool; #else @@ -257,7 +259,7 @@ class SymbolTimeline extends Timeline } #if !openfljs - __instanceFields = Type.getInstanceFields(Type.getClass(movieClip)); + __instanceFields = __getCachedInstanceFields(movieClip); #end enterFrame(1); @@ -535,6 +537,30 @@ class SymbolTimeline extends Timeline } } } + + @:noCompletion private static function __getCachedInstanceFields(displayObject:DisplayObject):Array + { + var clazz = Type.getClass(displayObject); + if (clazz == null) + { + return []; + } + + var className = Type.getClassName(clazz); + if (className == null) + { + return Type.getInstanceFields(clazz); + } + + var cached = __instanceFieldsCacheByClassName.get(className); + if (cached == null) + { + cached = Type.getInstanceFields(clazz); + __instanceFieldsCacheByClassName.set(className, cached); + } + + return cached; + } } #if !openfl_debug From 403cae889777c8a58c591986af887fc348d65926 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 21:41:59 -0400 Subject: [PATCH 19/41] Tools: cache generated classes for Flash/AIR libraries, and compile Animate classes when generate="true" (closes #47) Both library branches now share one cache, obj/libraries/.classes.json, holding the prefix, the classes to compile and the generated templates. The templates are added again on a cache hit, because Lime deletes generated files that are no longer project assets. With generate="true", Animate libraries now compile their generated classes, so symbols are created as their linkage class even when nothing else references it. Classes that depend on ActionScript classes from outside the library, such as fl.controls.* components, are left out, since they would not compile. --- scripts/Tools.hx | 148 +++++++++++++++----- src/swf/exporters/AnimateLibraryExporter.hx | 65 ++++++++- 2 files changed, 175 insertions(+), 38 deletions(-) diff --git a/scripts/Tools.hx b/scripts/Tools.hx index e0b8e2823..7ebd7892e 100644 --- a/scripts/Tools.hx +++ b/scripts/Tools.hx @@ -749,6 +749,29 @@ class Tools } } + private static function getGeneratedPath(project:HXProject):String + { + if (project.target == IOS) + { + return Path.tryFullPath(targetDirectory) + "/" + project.app.file + "/" + "/haxe/_generated"; + } + else + { + return Path.tryFullPath(targetDirectory) + "/haxe/_generated"; + } + } + + private static function isCacheCurrent(cacheFile:String, sourcePath:String):Bool + { + if (!FileSystem.exists(cacheFile)) return false; + + var cacheDate = FileSystem.stat(cacheFile).mtime; + var toolDate = FileSystem.stat(Haxelib.getPath(new Haxelib("swf"), true) + "/run.n").mtime; + var sourceDate = FileSystem.stat(sourcePath).mtime; + + return sourceDate.getTime() < cacheDate.getTime() && toolDate.getTime() < cacheDate.getTime(); + } + private static function processFile(sourcePath:String, targetPath:String, prefix:String = null, generate:Bool = false):Bool { if (targetPath == null) @@ -992,7 +1015,30 @@ class Tools if (library.generate != false) { - var generatedClasses = generateSWFClasses(project, output, swf, library.prefix); + var generatedClasses:Array = null; + var cacheFile = null; + + if (targetDirectory != null) + { + cacheFile = targetDirectory + "/obj/libraries/" + library.name + ".classes.json"; + generatedClasses = readGeneratedClassesCache(cacheFile, library.sourcePath, library.prefix, output); + } + + if (generatedClasses == null) + { + if (!Log.verbose) + { + Log.info("\x1b[1mProcessing library:\x1b[0m " + library.sourcePath + " [SWF]"); + } + + var firstTemplate = output.assets.length; + generatedClasses = generateSWFClasses(project, output, swf, library.prefix); + + if (cacheFile != null) + { + writeGeneratedClassesCache(cacheFile, library.prefix, generatedClasses, output.assets.slice(firstTemplate)); + } + } for (className in generatedClasses) { @@ -1024,15 +1070,18 @@ class Tools cacheDirectory = targetDirectory + "/obj/libraries"; cacheFile = cacheDirectory + "/" + library.name + ".zip"; - if (FileSystem.exists(cacheFile)) + var classesCacheFile = cacheDirectory + "/" + library.name + ".classes.json"; + var includedClasses:Array = null; + + cacheAvailable = isCacheCurrent(cacheFile, library.sourcePath); + + if (cacheAvailable && library.generate != false) { - var cacheDate = FileSystem.stat(cacheFile).mtime; - var toolDate = FileSystem.stat(Haxelib.getPath(new Haxelib("swf"), true) + "/run.n").mtime; - var sourceDate = FileSystem.stat(library.sourcePath).mtime; + includedClasses = readGeneratedClassesCache(classesCacheFile, library.sourcePath, library.prefix, output); - if (sourceDate.getTime() < cacheDate.getTime() && toolDate.getTime() < cacheDate.getTime()) + if (includedClasses == null) { - cacheAvailable = true; + cacheAvailable = false; } } @@ -1055,38 +1104,21 @@ class Tools if (library.generate != false) { - var targetPath:String; - - if (project.target == IOS) - { - targetPath = Path.tryFullPath(targetDirectory) + "/" + project.app.file + "/" + "/haxe/_generated"; - } - else - { - targetPath = Path.tryFullPath(targetDirectory) + "/haxe/_generated"; - } - - var generatedClasses = exporter.generateClasses(targetPath, output.assets, library.prefix); - - // for (className in generatedClasses) - // { - // output.haxeflags.push(className); - // } - - // if (cacheDirectory != null) - // { - // File.saveContent(cacheDirectory + "/classNames.txt", generatedClasses.join("\n")); - // } + var firstTemplate = output.assets.length; + includedClasses = []; + exporter.generateClasses(getGeneratedPath(project), output.assets, library.prefix, includedClasses); + writeGeneratedClassesCache(classesCacheFile, library.prefix, includedClasses, output.assets.slice(firstTemplate)); } } - else - { - // var generatedClasses = File.getContent(cacheDirectory + "/classNames.txt").split("\n"); - // for (className in generatedClasses) - // { - // output.haxeflags.push(className); - // } + // generate="true" also compiles the generated classes, so that symbols are + // created as their linkage class even when nothing else references it + if (library.generate == true) + { + for (className in includedClasses) + { + output.haxeflags.push(className); + } } var asset = new Asset(cacheFile, "lib/" + library.name + ".zip", AssetType.BUNDLE); @@ -1480,6 +1512,37 @@ class Tools return null; } + private static function readGeneratedClassesCache(cacheFile:String, sourcePath:String, prefix:String, output:HXProject):Array + { + if (!isCacheCurrent(cacheFile, sourcePath)) return null; + + var cache:Dynamic = null; + + try + { + cache = Json.parse(File.getContent(cacheFile)); + } + catch (e:Dynamic) + { + return null; + } + + if (cache == null || cache.prefix != (prefix != null ? prefix : "") || cache.classes == null || cache.templates == null) return null; + + // the generated class templates are added again, otherwise Lime deletes them as stale files + var templates:Array = cache.templates; + + for (template in templates) + { + var asset = new Asset("", template.targetPath, AssetType.TEMPLATE); + asset.data = template.data; + asset.embed = template.embed; + output.assets.push(asset); + } + + return cache.classes; + } + private static function readSWC(bytes:ByteArray):ByteArray { var input = new BytesInput(bytes); @@ -1519,4 +1582,17 @@ class Tools return result; } + + private static function writeGeneratedClassesCache(cacheFile:String, prefix:String, classes:Array, templates:Array):Void + { + var templateData = []; + + for (asset in templates) + { + templateData.push({targetPath: asset.targetPath, data: asset.data, embed: asset.embed}); + } + + System.mkdir(Path.directory(cacheFile)); + File.saveContent(cacheFile, Json.stringify({prefix: prefix != null ? prefix : "", classes: classes, templates: templateData})); + } } diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 58ee80721..b6b509c44 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -1151,7 +1151,14 @@ class AnimateLibraryExporter return; } - public function generateClasses(targetPath:String, output:Array, prefix:String = ""):Array + /** + Generates linkage classes as template assets, and returns their class names. + + If `includedClasses` is provided, it is filled with the generated classes that are + safe to compile without being referenced: classes that only depend on OpenFL and + other included classes, and not on ActionScript classes outside of this library. + **/ + public function generateClasses(targetPath:String, output:Array, prefix:String = "", includedClasses:Array = null):Array { #if commonjs var bitmapDataTemplate = File.getContent(Path.combine(js.Node.__dirname, "../templates/animate/BitmapData.mtt")); @@ -1164,6 +1171,7 @@ class AnimateLibraryExporter #end var generatedClasses = []; + var classDependencies = new Map>(); for (symbolID in libraryData.symbols.keys()) { @@ -1324,7 +1332,60 @@ class AnimateLibraryExporter templateFile.data = template.execute(context); output.push(templateFile); - generatedClasses.push((packageName.length > 0 ? packageName + "." : "") + name); + var generatedClassName = (packageName.length > 0 ? packageName + "." : "") + name; + generatedClasses.push(generatedClassName); + + var dependencies = []; + if (baseClassName != null) + { + dependencies.push(baseClassName); + } + for (property in classProperties) + { + var propertyType:String = property.type; + if (propertyType != "Dynamic" && !StringTools.startsWith(propertyType, "openfl.")) + { + dependencies.push(propertyType); + } + } + classDependencies.set(generatedClassName, dependencies); + } + } + + if (includedClasses != null) + { + var included = new Map(); + for (className in generatedClasses) + { + included.set(className, true); + } + + var changed = true; + while (changed) + { + changed = false; + for (className in generatedClasses) + { + if (!included.exists(className)) continue; + + for (dependency in classDependencies.get(className)) + { + if (!included.exists(dependency)) + { + included.remove(className); + changed = true; + break; + } + } + } + } + + for (className in generatedClasses) + { + if (included.exists(className)) + { + includedClasses.push(className); + } } } From ef7f3733a94a3bb4a7622ff03f5ac6239324ef13 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 21:41:59 -0400 Subject: [PATCH 20/41] AnimateLibrary: initialize generated classes from the library creating them (closes #46) Generated MovieClip and SimpleButton classes always initialized from the library they were generated for, so another library exporting the same class name created instances with the wrong symbol. Libraries now record the symbol being instantiated, and generated classes use it when they are that class or one of its base classes, falling back to their own library when created with "new". --- .../exporters/animate/AnimateButtonSymbol.hx | 2 +- .../exporters/animate/AnimateSpriteSymbol.hx | 2 +- src/swf/exporters/animate/AnimateSymbol.hx | 62 +++++++++++++++++++ templates/animate/MovieClip.mtt | 9 ++- templates/animate/SimpleButton.mtt | 9 ++- tests/src/TestMain.hx | 1 + tests/src/tests/AnimateGeneratedClassTest.hx | 50 +++++++++++++++ tests/src/tests/GeneratedTestClip.hx | 20 ++++++ 8 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 tests/src/tests/AnimateGeneratedClassTest.hx create mode 100644 tests/src/tests/GeneratedTestClip.hx diff --git a/src/swf/exporters/animate/AnimateButtonSymbol.hx b/src/swf/exporters/animate/AnimateButtonSymbol.hx index 602939eb1..1c43e3e01 100644 --- a/src/swf/exporters/animate/AnimateButtonSymbol.hx +++ b/src/swf/exporters/animate/AnimateButtonSymbol.hx @@ -65,7 +65,7 @@ class AnimateButtonSymbol extends AnimateSymbol if (symbolType != null) { - simpleButton = Type.createInstance(symbolType, []); + simpleButton = __createInstance(library, symbolType); } else { diff --git a/src/swf/exporters/animate/AnimateSpriteSymbol.hx b/src/swf/exporters/animate/AnimateSpriteSymbol.hx index 1a6bf2f1b..b475f0499 100644 --- a/src/swf/exporters/animate/AnimateSpriteSymbol.hx +++ b/src/swf/exporters/animate/AnimateSpriteSymbol.hx @@ -90,7 +90,7 @@ class AnimateSpriteSymbol extends AnimateSymbol if (symbolType != null) { - sprite = Type.createInstance(symbolType, []); + sprite = __createInstance(library, symbolType); } else { diff --git a/src/swf/exporters/animate/AnimateSymbol.hx b/src/swf/exporters/animate/AnimateSymbol.hx index 5ed699339..d1ed387c8 100644 --- a/src/swf/exporters/animate/AnimateSymbol.hx +++ b/src/swf/exporters/animate/AnimateSymbol.hx @@ -8,11 +8,49 @@ import openfl.display.DisplayObject; #end @:keepSub class AnimateSymbol { + // The symbol whose linkage class is being instantiated, so that a generated class + // initializes from the library creating it, even when another library exports a + // class with the same name + @:noCompletion private static var __pendingLibrary:AnimateLibrary; + @:noCompletion private static var __pendingSymbol:AnimateSymbol; + @:noCompletion private static var __pendingType:Class; + public var className:String; public var id:Int; public function new() {} + private function __createInstance(library:AnimateLibrary, type:Class):Dynamic + { + var previousLibrary = __pendingLibrary; + var previousSymbol = __pendingSymbol; + var previousType = __pendingType; + + __pendingLibrary = library; + __pendingSymbol = this; + __pendingType = type; + + var instance:Dynamic = null; + + try + { + instance = Type.createInstance(type, []); + } + catch (e:Dynamic) + { + __pendingLibrary = previousLibrary; + __pendingSymbol = previousSymbol; + __pendingType = previousType; + throw e; + } + + __pendingLibrary = previousLibrary; + __pendingSymbol = previousSymbol; + __pendingType = previousType; + + return instance; + } + private function __createObject(library:AnimateLibrary):DisplayObject { return null; @@ -20,5 +58,29 @@ import openfl.display.DisplayObject; private function __init(library:AnimateLibrary):Void {} + // Called by generated classes before super(). Returns false if the class is not + // being instantiated by a library, such as when it is created with "new". + private static function __initPending(type:Class):Bool + { + var pendingType = __pendingType; + + while (pendingType != null && pendingType != type) + { + pendingType = Type.getSuperClass(pendingType); + } + + if (pendingType == null) return false; + + var library = __pendingLibrary; + var symbol = __pendingSymbol; + + __pendingLibrary = null; + __pendingSymbol = null; + __pendingType = null; + + symbol.__init(library); + return true; + } + private function __initObject(library:AnimateLibrary, instance:DisplayObject):Void {} } diff --git a/templates/animate/MovieClip.mtt b/templates/animate/MovieClip.mtt index 5aeb90c31..813070b16 100644 --- a/templates/animate/MovieClip.mtt +++ b/templates/animate/MovieClip.mtt @@ -9,9 +9,12 @@ class ::CLASS_NAME:: extends ::if BASE_CLASS_NAME::::BASE_CLASS_NAME::::else::#i public function new() { - var library = swf.exporters.animate.AnimateLibrary.get("::UUID::"); - var symbol = library.symbols.get(::SYMBOL_ID::); - symbol.__init(library); + if (!swf.exporters.animate.AnimateSymbol.__initPending(::CLASS_NAME::)) + { + var library = swf.exporters.animate.AnimateLibrary.get("::UUID::"); + var symbol = library.symbols.get(::SYMBOL_ID::); + symbol.__init(library); + } super(); } diff --git a/templates/animate/SimpleButton.mtt b/templates/animate/SimpleButton.mtt index 95fec87dc..9bdd4c22b 100644 --- a/templates/animate/SimpleButton.mtt +++ b/templates/animate/SimpleButton.mtt @@ -6,9 +6,12 @@ class ::CLASS_NAME:: extends ::if BASE_CLASS_NAME::::BASE_CLASS_NAME::::else::#i { public function new() { - var library = swf.exporters.animate.AnimateLibrary.get("::UUID::"); - var symbol = library.symbols.get(::SYMBOL_ID::); - symbol.__init(library); + if (!swf.exporters.animate.AnimateSymbol.__initPending(::CLASS_NAME::)) + { + var library = swf.exporters.animate.AnimateLibrary.get("::UUID::"); + var symbol = library.symbols.get(::SYMBOL_ID::); + symbol.__init(library); + } super(); } diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index 7a1f16f02..742557921 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -9,6 +9,7 @@ class TestMain extends Sprite super(); var runner = new Runner(); + runner.addCase(new tests.AnimateGeneratedClassTest()); runner.addCase(new tests.AnimateShapeSymbolTest()); runner.addCase(new tests.ShapesTest()); diff --git a/tests/src/tests/AnimateGeneratedClassTest.hx b/tests/src/tests/AnimateGeneratedClassTest.hx new file mode 100644 index 000000000..082ce25c4 --- /dev/null +++ b/tests/src/tests/AnimateGeneratedClassTest.hx @@ -0,0 +1,50 @@ +package tests; + +import openfl.display.MovieClip; +import openfl.geom.Rectangle; +import swf.exporters.animate.AnimateFrame; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.animate.AnimateSpriteSymbol; +import swf.exporters.animate.AnimateSymbol; +import utest.Assert; +import utest.Test; + +@:access(swf.exporters.animate) +class AnimateGeneratedClassTest extends Test +{ + #if !flash + public function testGeneratedClassInitializesFromInstantiatingLibrary():Void + { + var libraryA = createLibrary(GeneratedTestClip.UUID, new Rectangle(1, 2, 3, 4)); + var libraryB = createLibrary("generated-class-test-b", new Rectangle(5, 6, 7, 8)); + + // both libraries export the same class, which was generated for library A + var clipB:MovieClip = cast libraryB.symbols.get(1).__createObject(libraryB); + Assert.equals(Type.getClassName(GeneratedTestClip), Type.getClassName(Type.getClass(clipB))); + Assert.same(new Rectangle(5, 6, 7, 8), clipB.scale9Grid); + + var clipA:MovieClip = cast libraryA.symbols.get(1).__createObject(libraryA); + Assert.same(new Rectangle(1, 2, 3, 4), clipA.scale9Grid); + + // created without a library, the generated class uses its own library + var clip = new GeneratedTestClip(); + Assert.same(new Rectangle(1, 2, 3, 4), clip.scale9Grid); + Assert.isNull(AnimateSymbol.__pendingSymbol); + } + #end + + private static function createLibrary(uuid:String, scale9Grid:Rectangle):AnimateLibrary + { + var symbol = new AnimateSpriteSymbol(); + symbol.id = 1; + symbol.className = Type.getClassName(GeneratedTestClip); + symbol.frames.push(new AnimateFrame()); + symbol.scale9Grid = scale9Grid; + + var library = new AnimateLibrary(uuid, uuid); + library.frameRate = 24; + library.symbols = new Map(); + library.symbols.set(symbol.id, symbol); + return library; + } +} diff --git a/tests/src/tests/GeneratedTestClip.hx b/tests/src/tests/GeneratedTestClip.hx new file mode 100644 index 000000000..bdb83164a --- /dev/null +++ b/tests/src/tests/GeneratedTestClip.hx @@ -0,0 +1,20 @@ +package tests; + +// Matches the class generated from templates/animate/MovieClip.mtt +@:access(swf.exporters.animate) +class GeneratedTestClip extends openfl.display.MovieClip +{ + public static inline var UUID = "generated-class-test-a"; + + public function new() + { + if (!swf.exporters.animate.AnimateSymbol.__initPending(GeneratedTestClip)) + { + var library = swf.exporters.animate.AnimateLibrary.get(UUID); + var symbol = library.symbols.get(1); + symbol.__init(library); + } + + super(); + } +} From 5a533b0c116d452d6da15361508c8510512ec1ac Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Mon, 26 Oct 2026 22:40:46 -0400 Subject: [PATCH 21/41] Export linked sounds in Animate libraries (closes #45) Linked DefineSound symbols are exported as sounds/.mp3, or as WAV for uncompressed PCM, and AnimateLibrary resolves their linkage names in getAudioBuffer() and loadAudioBuffer(). On HTML5, bundled sounds are decoded from the bundle bytes, which Lime does not do itself. ADPCM, Nellymoser and Speex sounds cannot be exported, so they are skipped with a warning. Speex now maps to its SWF format value (11) instead of 7, which made exports of SWF files with Speex sounds fail, and SWFLiteExporter records linked sound class names. This keeps the sound export from #45 without its additional linkage discovery or the list() changes. --- src/swf/exporters/AnimateLibraryExporter.hx | 142 ++++++++++++------ src/swf/exporters/SWFLiteExporter.hx | 6 +- src/swf/exporters/animate/AnimateLibrary.hx | 75 ++++++++- .../exporters/animate/AnimateSoundSymbol.hx | 15 ++ 4 files changed, 185 insertions(+), 53 deletions(-) create mode 100644 src/swf/exporters/animate/AnimateSoundSymbol.hx diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index b6b509c44..57663bf43 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -4,6 +4,9 @@ import format.png.Data; import format.png.Writer as PNGWriter; import swf.data.consts.BitmapFormat; import swf.data.consts.BlendMode; +import swf.data.consts.SoundCompression; +import swf.data.consts.SoundRate; +import swf.data.consts.SoundSize; import swf.data.filters.IFilter; import swf.data.SWFButtonRecord; import swf.data.SWFSymbol; @@ -51,6 +54,7 @@ import openfl.text.TextFormatAlign; import openfl.utils.AssetManifest; import openfl.utils.AssetType; import openfl.utils.ByteArray; +import openfl.utils.Endian; import haxe.crypto.Crc32; import haxe.io.Bytes; import haxe.io.BytesOutput; @@ -1081,13 +1085,12 @@ class AnimateLibraryExporter return symbol; } - private function addSound(tag:IDefinitionTag):Void + private function addSound(tag:IDefinitionTag):Dynamic { if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (tag, TagDefineSound)) { var defineSound:TagDefineSound = cast tag; - var byteArray = defineSound.soundData; var type:SoundType = switch (defineSound.soundFormat) { case 0: SoundType.UNCOMPRESSED_NATIVE_ENDIAN; @@ -1097,58 +1100,96 @@ class AnimateLibraryExporter case 4: SoundType.NELLYMOSER_16_KHZ; case 5: SoundType.NELLYMOSER_8_KHZ; case 6: SoundType.NELLYMOSER; - case 7: SoundType.SPEEX; + case 11: SoundType.SPEEX; case _: throw("invalid sound type!"); } - // TODO - - // var entry:Entry = { - // fileName: symbol.path, - // fileSize: byteArray.length, - // fileTime: Date.now(), - // compressed: false, - // dataSize: 0, - // data: byteArray, - // crc32: Crc32.make(byteArray) - // }; - // outputList.add(entry); - - // createdDirectory = false; - // for (id in exporter.sounds.keys()) - // { - // if (!createdDirectory) - // { - // System.mkdir(Path.combine(targetPath, "sounds")); - // createdDirectory = true; - // } - - // var symbolClassName = exporter.soundSymbolClassNames.get(id); - // var typeId = exporter.soundTypes.get(id); - - // Log.info("", " - \x1b[1mExporting sound:\x1b[0m [id=" + id + ", type=" + typeId + ", symbolClassName=" + symbolClassName + "]"); - - // var type; - // switch (typeId) - // { - // case SoundType.MP3: - // type = "mp3"; - // case SoundType.ADPCM: - // type = "adpcm"; - // case _: - // throw "unsupported sound type " + id + ", type " + typeId + ", symbol class name " + symbolClassName; - // }; - // var path = "sounds/" + symbolClassName + "." + type; - // var assetData = exporter.sounds.get(id); - - // File.saveBytes(Path.combine(targetPath, path), assetData); - - // // NOTICE: everything must be .mp3 in its final form, even though we write out various formats to disk - // var soundAsset = new Asset("", "sounds/" + symbolClassName + ".mp3", AssetType.SOUND); - // project.assets.push(soundAsset); + var byteArray:ByteArray = null; + var extension:String = null; + + switch (type) + { + case MP3: + // skip SeekSamples, the rest is MP3 frames + byteArray = new ByteArray(); + if (defineSound.soundData.length > 2) + { + byteArray.writeBytes(defineSound.soundData, 2, defineSound.soundData.length - 2); + } + extension = "mp3"; + + case UNCOMPRESSED_NATIVE_ENDIAN, UNCOMPRESSED_LITTLE_ENDIAN: + byteArray = createWAV(defineSound); + extension = "wav"; + + default: + Log.warn("Skipping sound " + tag.characterId + ", " + SoundCompression.toString(defineSound.soundFormat) + + " sounds cannot be exported"); + return null; + } + + var symbol:Dynamic = {}; + symbol.type = SWFSymbolType.SOUND; + symbol.id = tag.characterId; + symbol.path = "sounds/" + symbol.id + "." + extension; + + var entry:Entry = { + fileName: symbol.path, + fileSize: byteArray.length, + fileTime: Date.now(), + compressed: false, + dataSize: 0, + data: byteArray, + crc32: Crc32.make(byteArray) + }; + outputList.add(entry); + + manifestData.assets.push({ + path: symbol.path, + type: AssetType.SOUND + }); + + libraryData.symbols.set(symbol.id, symbol); + + return symbol; } - return; + return null; + } + + private function createWAV(defineSound:TagDefineSound):ByteArray + { + var pcm = defineSound.soundData; + var channels = defineSound.soundType == swf.data.consts.SoundType.STEREO ? 2 : 1; + var bitsPerSample = defineSound.soundSize == SoundSize.BIT_16 ? 16 : 8; + var sampleRate = switch (defineSound.soundRate) + { + case SoundRate.KHZ_5: 5512; + case SoundRate.KHZ_11: 11025; + case SoundRate.KHZ_22: 22050; + default: 44100; + } + var blockAlign = channels * Std.int(bitsPerSample / 8); + var padding = pcm.length % 2; + + var wav = new ByteArray(); + wav.endian = Endian.LITTLE_ENDIAN; + wav.writeUTFBytes("RIFF"); + wav.writeInt(36 + pcm.length + padding); + wav.writeUTFBytes("WAVE"); + wav.writeUTFBytes("fmt "); + wav.writeInt(16); + wav.writeShort(1); // PCM + wav.writeShort(channels); + wav.writeInt(sampleRate); + wav.writeInt(sampleRate * blockAlign); + wav.writeShort(blockAlign); + wav.writeShort(bitsPerSample); + wav.writeUTFBytes("data"); + wav.writeInt(pcm.length); + wav.writeBytes(pcm, 0, pcm.length); + if (padding > 0) wav.writeByte(0); + return wav; } /** @@ -1448,7 +1489,7 @@ class AnimateLibraryExporter } else if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (tag, TagDefineSound)) { - addSound(tag); + return addSound(tag); } return null; @@ -1633,6 +1674,7 @@ private #if (haxe_ver >= 4.0) enum #end abstract SWFSymbolType(Int) from Int to public var SHAPE = 4; public var SPRITE = 5; public var STATIC_TEXT = 6; + public var SOUND = 7; } #if (haxe_ver < 4.0) @:enum #end diff --git a/src/swf/exporters/SWFLiteExporter.hx b/src/swf/exporters/SWFLiteExporter.hx index 0d341b88c..f4e1eb947 100644 --- a/src/swf/exporters/SWFLiteExporter.hx +++ b/src/swf/exporters/SWFLiteExporter.hx @@ -844,7 +844,7 @@ class SWFLiteExporter case 4: SoundType.NELLYMOSER_16_KHZ; case 5: SoundType.NELLYMOSER_8_KHZ; case 6: SoundType.NELLYMOSER; - case 7: SoundType.SPEEX; + case 11: SoundType.SPEEX; case _: throw("invalid sound type!"); } sounds.set(tag.characterId, byteArray); @@ -870,6 +870,10 @@ class SWFLiteExporter data2.className = symbol.name; data2.baseClassName = FrameScriptParser.getBaseClassName(data, symbol.name); } + else if (symbol.name != null && sounds.exists(symbol.tagId)) + { + soundSymbolClassNames.set(symbol.tagId, symbol.name); + } } private function processTag(tag:IDefinitionTag):Dynamic diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index d6c1b5bff..c02a1b33b 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -19,6 +19,7 @@ import openfl.utils.AssetType; import openfl.utils.Future; import openfl.utils.Promise; #if lime +import lime.media.AudioBuffer; import lime.utils.AssetBundle; import lime.utils.AssetManifest; import lime.utils.AssetLibrary as LimeAssetLibrary; @@ -64,6 +65,7 @@ import openfl.filters.GlowFilter; private var preloading:Bool; private var root:AnimateSpriteSymbol; private var rootPath:String; + private var soundClassNames:Map; private var symbols:Map; private var symbolsByClassName:Map; @@ -77,6 +79,7 @@ import openfl.filters.GlowFilter; alphaCheck = new Map(); bitmapClassNames = new Map(); + soundClassNames = new Map(); #if (ios || tvos) rootPath = "assets/"; @@ -127,7 +130,12 @@ import openfl.filters.GlowFilter; if (type == null || type == (cast AssetType.IMAGE) || type == (cast AssetType.MOVIE_CLIP)) { - return (symbolsByClassName != null && symbolsByClassName.exists(id)); + return (symbolsByClassName != null && symbolsByClassName.exists(id)) || (type == null && soundClassNames.exists(id)); + } + + if ((type == (cast AssetType.SOUND) || type == (cast AssetType.MUSIC)) && soundClassNames.exists(id)) + { + return true; } } @@ -140,6 +148,13 @@ import openfl.filters.GlowFilter; return instances.get(uuid); } + #if lime + public override function getAudioBuffer(id:String):AudioBuffer + { + return super.getAudioBuffer(__getSoundID(id)); + } + #end + #if lime public override function getImage(id:String):Image { @@ -257,6 +272,7 @@ import openfl.filters.GlowFilter; var data:Dynamic, type:SWFSymbolType, symbol:AnimateSymbol = null; var bitmapSymbol:AnimateBitmapSymbol; + var soundSymbol:AnimateSoundSymbol; var spriteSymbol:AnimateSpriteSymbol; symbols = new Map(); @@ -268,6 +284,7 @@ import openfl.filters.GlowFilter; data = symbolData[i]; if (data == null) continue; type = data.type; + symbol = null; switch (type) { @@ -284,6 +301,10 @@ import openfl.filters.GlowFilter; symbol = __parseFont(data); case SHAPE: symbol = __parseShape(data); + case SOUND: + soundSymbol = __parseSound(data); + if (soundSymbol.className != null) soundClassNames.set(soundSymbol.className, soundSymbol.path); + symbol = soundSymbol; case SPRITE: spriteSymbol = __parseSprite(data); if (i == rootIndex) root = spriteSymbol; @@ -295,7 +316,8 @@ import openfl.filters.GlowFilter; if (symbol == null) continue; symbols.set(symbol.id, symbol); - if (symbol.className != null) + // sounds are not display objects, so they are only found through soundClassNames + if (symbol.className != null && type != SOUND) { symbolsByClassName.set(symbol.className, symbol); #if (openfl > "9.1.0") @@ -360,6 +382,13 @@ import openfl.filters.GlowFilter; } #end + #if lime + public override function loadAudioBuffer(id:String):Future + { + return super.loadAudioBuffer(__getSoundID(id)); + } + #end + public static function loadFromBundle(bundle:AssetBundle):#if (java && lime) Future #else Future #end { var library = AssetLibrary.fromBundle(bundle); @@ -510,6 +539,38 @@ import openfl.filters.GlowFilter; } #end + #if lime + private function __getSoundID(id:String):String + { + if (soundClassNames.exists(id)) + { + id = soundClassNames.get(id); + } + + // Lime keeps bundled sounds as bytes on HTML5, and does not decode them later + if (!cachedAudioBuffers.exists(id) && cachedBytes.exists(id)) + { + var bytes = cachedBytes.get(id); + #if (js && html5 && lime_howlerjs) + // AudioBuffer.fromBytes() cannot detect MP3 data without an ID3 tag, which + // SWF files do not include, so use the file extension from the exporter + var audioBuffer = new AudioBuffer(); + audioBuffer.src = new lime.media.howlerjs.Howl({ + src: ["data:audio/" + haxe.io.Path.extension(id) + ";base64," + haxe.crypto.Base64.encode(bytes)], + html5: true, + preload: false + }); + cachedAudioBuffers.set(id, audioBuffer); + #else + cachedAudioBuffers.set(id, AudioBuffer.fromBytes(bytes)); + #end + cachedBytes.remove(id); + } + + return id; + } + #end + #if lime private function __load():Future { @@ -693,6 +754,15 @@ import openfl.filters.GlowFilter; return symbol; } + private function __parseSound(data:Dynamic):AnimateSoundSymbol + { + var symbol = new AnimateSoundSymbol(); + symbol.id = data.id; + symbol.className = data.className; + symbol.path = data.path; + return symbol; + } + private function __parseSprite(data:Dynamic):AnimateSpriteSymbol { if (data == null) return null; @@ -792,4 +862,5 @@ import openfl.filters.GlowFilter; public var SHAPE = 4; public var SPRITE = 5; public var STATIC_TEXT = 6; + public var SOUND = 7; } diff --git a/src/swf/exporters/animate/AnimateSoundSymbol.hx b/src/swf/exporters/animate/AnimateSoundSymbol.hx new file mode 100644 index 000000000..7f5bc21b6 --- /dev/null +++ b/src/swf/exporters/animate/AnimateSoundSymbol.hx @@ -0,0 +1,15 @@ +package swf.exporters.animate; + +#if !openfl_debug +@:fileXml('tags="haxe,release"') +@:noDebug +#end +class AnimateSoundSymbol extends AnimateSymbol +{ + public var path:String; + + public function new() + { + super(); + } +} From 0617888b6e0bc0fff05baba427371b826579446d Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 00:32:22 -0400 Subject: [PATCH 22/41] Tests: cover fonts, timelines, shape exports, Animate libraries and SWFLite exports Adds tests for each fix in this release: DefineFont2/3 parsing, clipDepth masks and instance reuse on both timelines, named child fields, shape export output and line gradients, Animate libraries without a UUID, linked sounds, and SWFLite root and sound linkage. --- tests/src/TestMain.hx | 7 + tests/src/tests/AnimateLibraryTest.hx | 163 ++++++++++++++++++++ tests/src/tests/SWFBuilder.hx | 87 +++++++++++ tests/src/tests/SWFLiteExporterTest.hx | 44 ++++++ tests/src/tests/ShapeBuilder.hx | 161 ++++++++++++++++++++ tests/src/tests/ShapeExportTest.hx | 175 +++++++++++++++++++++ tests/src/tests/TagDefineFont2Test.hx | 105 +++++++++++++ tests/src/tests/TimelineTest.hx | 202 +++++++++++++++++++++++++ 8 files changed, 944 insertions(+) create mode 100644 tests/src/tests/AnimateLibraryTest.hx create mode 100644 tests/src/tests/SWFBuilder.hx create mode 100644 tests/src/tests/SWFLiteExporterTest.hx create mode 100644 tests/src/tests/ShapeBuilder.hx create mode 100644 tests/src/tests/ShapeExportTest.hx create mode 100644 tests/src/tests/TagDefineFont2Test.hx create mode 100644 tests/src/tests/TimelineTest.hx diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index 742557921..d9ef2eefb 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -10,8 +10,15 @@ class TestMain extends Sprite var runner = new Runner(); runner.addCase(new tests.AnimateGeneratedClassTest()); + runner.addCase(new tests.AnimateLibraryTest()); runner.addCase(new tests.AnimateShapeSymbolTest()); + runner.addCase(new tests.ShapeExportTest()); runner.addCase(new tests.ShapesTest()); + #if !flash + runner.addCase(new tests.SWFLiteExporterTest()); + #end + runner.addCase(new tests.TagDefineFont2Test()); + runner.addCase(new tests.TimelineTest()); Report.create(runner); diff --git a/tests/src/tests/AnimateLibraryTest.hx b/tests/src/tests/AnimateLibraryTest.hx new file mode 100644 index 000000000..f735df764 --- /dev/null +++ b/tests/src/tests/AnimateLibraryTest.hx @@ -0,0 +1,163 @@ +package tests; + +import haxe.Json; +import haxe.crypto.Crc32; +import haxe.io.Bytes; +import haxe.io.BytesOutput; +import haxe.zip.Entry; +import haxe.zip.Writer; +import lime.media.AudioBuffer; +import openfl.utils.AssetType; +import swf.exporters.animate.AnimateLibrary; +import utest.Assert; +import utest.Async; +import utest.Test; + +@:access(lime.utils.AssetLibrary) +@:access(swf.exporters.animate.AnimateLibrary) +class AnimateLibraryTest extends Test +{ + public function testLibraryWithoutUUID():Void + { + var library = new AnimateLibrary("animate-library-test"); + Assert.equals(library, AnimateLibrary.get("animate-library-test")); + + library.unload(); + Assert.isNull(AnimateLibrary.get("animate-library-test")); + } + + public function testLinkedSoundLookup():Void + { + var library = new AnimateLibrary("animate-library-sound-test"); + var buffer = new AudioBuffer(); + library.soundClassNames.set("TestSound", "sounds/1.wav"); + library.cachedAudioBuffers.set("sounds/1.wav", buffer); + library.symbols = new Map(); + library.symbolsByClassName = new Map(); + library.bitmapSymbols = []; + + Assert.equals(buffer, library.getAudioBuffer("TestSound")); + Assert.isTrue(library.exists("TestSound", cast AssetType.SOUND)); + Assert.isTrue(library.exists("TestSound", cast AssetType.MUSIC)); + Assert.isTrue(library.exists("TestSound", null)); + Assert.isFalse(library.exists("TestSound", cast AssetType.MOVIE_CLIP)); + Assert.isFalse(library.exists("TestSound", cast AssetType.IMAGE)); + + var loaded:AudioBuffer = null; + library.loadAudioBuffer("TestSound").onComplete(function(value) loaded = value); + Assert.equals(buffer, loaded); + + library.unload(); + } + + #if !flash + public function testBundledSoundBytesAreDecoded():Void + { + // on HTML5, Lime leaves bundled sounds as bytes + var library = new AnimateLibrary("animate-library-bytes-test"); + library.soundClassNames.set("TestSound", "sounds/1.wav"); + library.cachedBytes.set("sounds/1.wav", createWAV()); + + var buffer = library.getAudioBuffer("TestSound"); + Assert.notNull(buffer); + Assert.equals(11025, buffer.sampleRate); + Assert.equals(1, buffer.channels); + Assert.isFalse(library.cachedBytes.exists("sounds/1.wav")); + + library.unload(); + } + + public function testLoadLinkedSound(async:Async):Void + { + var data = { + version: 0.1, + frameRate: 24, + root: -1, + uuid: "animate-library-load-test", + symbols: ([ + {type: 7, id: 1, className: "TestSound", path: "sounds/1.wav"}, + // unknown symbol types are skipped, without registering the previous symbol again + {type: 99, id: 2, className: "Unknown"} + ] : Array) + }; + var manifest = { + name: null, + rootPath: null, + version: 3, + libraryType: "swf.exporters.animate.AnimateLibrary", + libraryArgs: ["data.json", "animate-library-load-test"], + assets: [{path: "data.json", type: "TEXT"}, {path: "sounds/1.wav", type: "SOUND"}] + }; + + var zip = createZip([ + "library.json" => Bytes.ofString(Json.stringify(manifest)), + "data.json" => Bytes.ofString(Json.stringify(data)), + "sounds/1.wav" => createWAV() + ]); + + AnimateLibrary.loadFromBytes(zip).onComplete(function(value) + { + var library:AnimateLibrary = cast value; + var buffer = library.getAudioBuffer("TestSound"); + Assert.notNull(buffer); + Assert.equals(11025, buffer.sampleRate); + Assert.isTrue(library.exists("TestSound", cast AssetType.SOUND)); + Assert.isFalse(library.symbolsByClassName.exists("TestSound")); + Assert.isFalse(library.symbolsByClassName.exists("Unknown")); + Assert.isFalse(library.symbols.exists(2)); + library.unload(); + async.done(); + }).onError(function(error) + { + Assert.fail("Could not load library: " + error); + async.done(); + }); + } + + private static function createWAV():Bytes + { + var samples = 1102; + var output = new BytesOutput(); + output.writeString("RIFF"); + output.writeInt32(36 + samples * 2); + output.writeString("WAVE"); + output.writeString("fmt "); + output.writeInt32(16); + output.writeUInt16(1); + output.writeUInt16(1); + output.writeInt32(11025); + output.writeInt32(11025 * 2); + output.writeUInt16(2); + output.writeUInt16(16); + output.writeString("data"); + output.writeInt32(samples * 2); + for (i in 0...samples) + { + output.writeInt16(Std.int(Math.sin(i / 4) * 8000)); + } + return output.getBytes(); + } + + private static function createZip(files:Map):Bytes + { + var entries = new List(); + for (path in files.keys()) + { + var bytes = files.get(path); + entries.add({ + fileName: path, + fileSize: bytes.length, + fileTime: Date.now(), + compressed: false, + dataSize: 0, + data: bytes, + crc32: Crc32.make(bytes) + }); + } + + var output = new BytesOutput(); + new Writer(output).write(entries); + return output.getBytes(); + } + #end +} diff --git a/tests/src/tests/SWFBuilder.hx b/tests/src/tests/SWFBuilder.hx new file mode 100644 index 000000000..ee85f0883 --- /dev/null +++ b/tests/src/tests/SWFBuilder.hx @@ -0,0 +1,87 @@ +package tests; + +import openfl.utils.ByteArray; +import swf.SWF; +import swf.SWFData; +import swf.SWFRoot; +import swf.data.SWFSymbol; +import swf.tags.ITag; +import swf.tags.TagDefineSprite; +import swf.tags.TagEnd; +import swf.tags.TagShowFrame; +import swf.tags.TagSymbolClass; + +// Writes a minimal uncompressed SWF file from tags created in code +class SWFBuilder +{ + private var symbolClass:TagSymbolClass; + private var tags:Array; + + public function new() + { + tags = []; + symbolClass = new TagSymbolClass(); + } + + public function add(tag:ITag):SWFBuilder + { + tags.push(tag); + return this; + } + + public function link(characterId:Int, className:String):SWFBuilder + { + symbolClass.symbols.push(SWFSymbol.create(characterId, className)); + return this; + } + + public function build():ByteArray + { + var output = new SWFData(); + output.writeUI8(0x46); + output.writeUI8(0x57); + output.writeUI8(0x53); + output.writeUI8(10); + output.writeUI32(0); + // RECT with 12 bit values, for a 100x100 stage + for (value in [0x60, 0x00, 0x3E, 0x80, 0x00, 0x3E, 0x80]) + { + output.writeUI8(value); + } + output.writeUI8(0x00); + output.writeUI8(0x18); + output.writeUI16(1); + + for (tag in tags) + { + tag.publish(output, 10); + } + if (symbolClass.symbols.length > 0) + { + symbolClass.publish(output, 10); + } + new TagShowFrame().publish(output, 10); + new TagEnd().publish(output, 10); + + var length = output.length; + output.position = 4; + output.writeUI32(length); + output.position = 0; + return output; + } + + public function parse():SWFRoot + { + return new SWF(build()).data; + } + + public static function createSprite(characterId:Int):TagDefineSprite + { + var sprite = new TagDefineSprite(); + sprite.characterId = characterId; + sprite.frameCount = 1; + sprite.tags.push(new TagShowFrame()); + sprite.tags.push(new TagEnd()); + return sprite; + } +} diff --git a/tests/src/tests/SWFLiteExporterTest.hx b/tests/src/tests/SWFLiteExporterTest.hx new file mode 100644 index 000000000..420f415da --- /dev/null +++ b/tests/src/tests/SWFLiteExporterTest.hx @@ -0,0 +1,44 @@ +package tests; + +import openfl.utils.ByteArray; +import swf.data.consts.SoundCompression; +import swf.data.consts.SoundRate; +import swf.data.consts.SoundSize; +import swf.exporters.SWFLiteExporter; +import swf.tags.TagDefineSound; +import utest.Assert; +import utest.Test; + +class SWFLiteExporterTest extends Test +{ + #if !flash + public function testSymbolClassZeroIsRoot():Void + { + // the document class is linked to character 0, and is not always named *_fla.MainTimeline + var exporter = new SWFLiteExporter(new SWFBuilder().link(0, "MainDocument").parse()); + Assert.equals("MainDocument", exporter.swfLite.root.className); + } + + public function testLinkedSoundClassNames():Void + { + var data = new ByteArray(); + for (i in 0...16) + { + data.writeByte(i); + } + + var swf = new SWFBuilder() + .add(TagDefineSound.create(1, SoundCompression.UNCOMPRESSED_LITTLE_ENDIAN, SoundRate.KHZ_11, SoundSize.BIT_16, swf.data.consts.SoundType.MONO, 8, data)) + .add(TagDefineSound.create(2, SoundCompression.SPEEX, SoundRate.KHZ_5, SoundSize.BIT_16, swf.data.consts.SoundType.MONO, 8, data)) + .link(1, "TestSound") + .link(2, "TestSpeex") + .parse(); + + var exporter = new SWFLiteExporter(swf); + Assert.equals("TestSound", exporter.soundSymbolClassNames.get(1)); + Assert.equals("TestSpeex", exporter.soundSymbolClassNames.get(2)); + Assert.equals("UNCOMPRESSED_LITTLE_ENDIAN", Std.string(exporter.soundTypes.get(1))); + Assert.equals("SPEEX", Std.string(exporter.soundTypes.get(2))); + } + #end +} diff --git a/tests/src/tests/ShapeBuilder.hx b/tests/src/tests/ShapeBuilder.hx new file mode 100644 index 000000000..2e4746e40 --- /dev/null +++ b/tests/src/tests/ShapeBuilder.hx @@ -0,0 +1,161 @@ +package tests; + +import swf.data.SWFFillStyle; +import swf.data.SWFGradient; +import swf.data.SWFGradientRecord; +import swf.data.SWFLineStyle2; +import swf.data.SWFMatrix; +import swf.data.SWFShapeRecordCurvedEdge; +import swf.data.SWFShapeRecordEnd; +import swf.data.SWFShapeRecordStraightEdge; +import swf.data.SWFShapeRecordStyleChange; +import swf.data.SWFShapeWithStyle; +import swf.exporters.core.ShapeCommand; + +// Builds DefineShape4 records in code, in twips like a SWF file +class ShapeBuilder +{ + public var shape(default, null):SWFShapeWithStyle; + + public function new() + { + shape = new SWFShapeWithStyle(null, 4); + } + + public function addSolidFill(argb:Int):ShapeBuilder + { + var fillStyle = new SWFFillStyle(); + fillStyle.type = 0x00; + fillStyle.rgb = argb; + shape.initialFillStyles.push(fillStyle); + return this; + } + + public function addLine(width:Int, argb:Int):ShapeBuilder + { + var lineStyle = new SWFLineStyle2(null, 4); + lineStyle.width = width; + lineStyle.color = argb; + shape.initialLineStyles.push(lineStyle); + return this; + } + + public function addGradientLine(width:Int, colors:Array, ratios:Array):ShapeBuilder + { + var gradient = new SWFGradient(); + for (i in 0...colors.length) + { + var record = new SWFGradientRecord(); + record.color = colors[i]; + record.ratio = ratios[i]; + gradient.records.push(record); + } + + var fillStyle = new SWFFillStyle(); + fillStyle.type = 0x10; + fillStyle.gradient = gradient; + fillStyle.gradientMatrix = new SWFMatrix(); + + var lineStyle = new SWFLineStyle2(null, 4); + lineStyle.width = width; + lineStyle.hasFillFlag = true; + lineStyle.fillType = fillStyle; + shape.initialLineStyles.push(lineStyle); + return this; + } + + // fill and line style indexes start at 1, 0 clears a style and -1 leaves it unchanged + public function move(x:Int, y:Int, fill0:Int = -1, fill1:Int = -1, line:Int = -1):ShapeBuilder + { + addStyleChange(true, x, y, fill0, fill1, line); + return this; + } + + public function style(fill0:Int = -1, fill1:Int = -1, line:Int = -1):ShapeBuilder + { + addStyleChange(false, 0, 0, fill0, fill1, line); + return this; + } + + private function addStyleChange(moveTo:Bool, x:Int, y:Int, fill0:Int, fill1:Int, line:Int):Void + { + var record = new SWFShapeRecordStyleChange(); + if (moveTo) + { + record.stateMoveTo = true; + record.moveDeltaX = x; + record.moveDeltaY = y; + } + if (fill0 >= 0) + { + record.stateFillStyle0 = true; + record.fillStyle0 = fill0; + } + if (fill1 >= 0) + { + record.stateFillStyle1 = true; + record.fillStyle1 = fill1; + } + if (line >= 0) + { + record.stateLineStyle = true; + record.lineStyle = line; + } + shape.records.push(record); + } + + public function lineTo(deltaX:Int, deltaY:Int):ShapeBuilder + { + var record = new SWFShapeRecordStraightEdge(); + record.generalLineFlag = true; + record.deltaX = deltaX; + record.deltaY = deltaY; + shape.records.push(record); + return this; + } + + public function curveTo(controlDeltaX:Int, controlDeltaY:Int, anchorDeltaX:Int, anchorDeltaY:Int):ShapeBuilder + { + var record = new SWFShapeRecordCurvedEdge(); + record.controlDeltaX = controlDeltaX; + record.controlDeltaY = controlDeltaY; + record.anchorDeltaX = anchorDeltaX; + record.anchorDeltaY = anchorDeltaY; + shape.records.push(record); + return this; + } + + public function end():SWFShapeWithStyle + { + shape.records.push(new SWFShapeRecordEnd()); + return shape; + } + + // A readable description of shape commands, with numbers rounded so it is the same on every target + public static function describe(commands:Array):String + { + var lines = []; + for (command in commands) + { + lines.push(switch (command) + { + case BeginBitmapFill(bitmap, _, repeat, smooth): "bitmapFill " + bitmap + " " + repeat + " " + smooth; + case BeginFill(color, alpha): "fill " + StringTools.hex(color, 6) + " " + number(alpha); + case BeginGradientFill(_, colors, _, ratios, _, _, _, _): "gradientFill " + colors.length + " " + ratios.join(","); + case CurveTo(controlX, controlY, anchorX, anchorY): "curve " + number(controlX) + " " + number(controlY) + " " + number(anchorX) + " " + + number(anchorY); + case EndFill: "endFill"; + case LineStyle(thickness, color, _, _, _, _, _, _): thickness == null ? "noLine" : "line " + number(thickness) + " " + StringTools.hex(color, 6); + case LineGradientStyle(_, colors, _, ratios, _, _, _, _): "lineGradient " + colors.length + " " + ratios.join(","); + case LineTo(x, y): "lineTo " + number(x) + " " + number(y); + case MoveTo(x, y): "moveTo " + number(x) + " " + number(y); + }); + } + return lines.join("\n"); + } + + private static function number(value:Float):String + { + return Std.string(Math.round(value * 100) / 100); + } +} diff --git a/tests/src/tests/ShapeExportTest.hx b/tests/src/tests/ShapeExportTest.hx new file mode 100644 index 000000000..7bb070d36 --- /dev/null +++ b/tests/src/tests/ShapeExportTest.hx @@ -0,0 +1,175 @@ +package tests; + +import openfl.display.Shape; +import swf.exporters.ShapeCommandExporter; +import swf.exporters.core.ShapeCommand; +import swf.exporters.swflite.SWFLite; +import swf.exporters.swflite.ShapeSymbol; +import swf.tags.TagDefineMorphShape; +import swf.tags.TagDefineShape; +import utest.Assert; +import utest.Test; + +#if !flash +@:access(openfl.display.Graphics) +@:access(openfl.display._internal.DrawCommandBuffer) +#end +@:access(swf.exporters.swflite.ShapeSymbol) +class ShapeExportTest extends Test +{ + // Two squares that share an edge, a closed curve, and a triangle whose edges are given out of order + public static function createComplexShape():ShapeBuilder + { + return new ShapeBuilder() + .addSolidFill(0xFFFF0000) + .addSolidFill(0xFF0000FF) + .addLine(20, 0xFF000000) + .move(0, 0, 0, 1, 1) + .lineTo(1000, 0) + .lineTo(0, 1000) + .style(2, 1) + .lineTo(-1000, 0) + .style(0, 1) + .lineTo(0, -1000) + .move(1000, 1000, 0, 2) + .lineTo(0, 1000) + .lineTo(-1000, 0) + .lineTo(0, -1000) + .move(3000, 0, 0, 1, 0) + .curveTo(500, 500, 500, -500) + .lineTo(-1000, 0) + .move(5000, 1000, 0, 2) + .lineTo(1000, 0) + .move(5500, 0) + .lineTo(-500, 1000) + .move(6000, 1000) + .lineTo(-500, -1000); + } + + public function testComplexShapeExport():Void + { + var exporter = new ShapeCommandExporter(null); + createComplexShape().end().export(exporter); + + // recorded from the exporter before it was optimized, which must not change the output + Assert.equals(EXPECTED_COMPLEX_SHAPE, ShapeBuilder.describe(exporter.commands)); + } + + public function testLineGradientStyleExport():Void + { + var exporter = new ShapeCommandExporter(null); + createGradientLineShape().export(exporter); + + Assert.equals("line 1 000000\nlineGradient 2 0,255\nmoveTo 0 0\nlineTo 50 0", ShapeBuilder.describe(exporter.commands)); + } + + #if !flash + public function testRuntimeShapeDrawsLineGradient():Void + { + var tag = new TagDefineShape(); + tag.shapes = createGradientLineShape(); + + var shape = new swf.runtime.Shape(null, tag); + Assert.isTrue(hasLineGradient(shape)); + } + + public function testRuntimeMorphShapeDrawsLineGradient():Void + { + var morphShape = new swf.runtime.MorphShape(null, new GradientLineMorphShape()); + morphShape.render(); + Assert.isTrue(hasLineGradient(morphShape)); + } + + public function testSWFLiteShapeDrawsLineGradient():Void + { + var symbol = new ShapeSymbol(); + symbol.commands = [ + LineStyle(1, 0, 1, false, null, null, null, null), + LineGradientStyle(LINEAR, [0xFF0000, 0x0000FF], [1, 1], [0, 255], null, PAD, RGB, 0), + MoveTo(0, 0), + LineTo(50, 0) + ]; + + var shape = symbol.__createObject(new SWFLite()); + Assert.isTrue(hasLineGradient(shape)); + } + + private static function hasLineGradient(shape:Shape):Bool + { + for (type in shape.graphics.__commands.types) + { + switch (type) + { + case LINE_GRADIENT_STYLE: + return true; + default: + } + } + return false; + } + #end + + private static function createGradientLineShape():swf.data.SWFShapeWithStyle + { + return new ShapeBuilder() + .addGradientLine(20, [0xFFFF0000, 0xFF0000FF], [0, 255]) + .move(0, 0, -1, -1, 1) + .lineTo(1000, 0) + .end(); + } + + private static var EXPECTED_COMPLEX_SHAPE = [ + "noLine", + "fill FF0000 1", + "moveTo 0 0", + "lineTo 50 0", + "lineTo 50 50", + "lineTo 0 50", + "lineTo 0 0", + "moveTo 150 0", + "curve 175 25 200 0", + "lineTo 150 0", + "endFill", + "fill 0000FF 1", + "moveTo 0 50", + "lineTo 50 50", + "lineTo 50 100", + "lineTo 0 100", + "lineTo 0 50", + "moveTo 250 50", + "lineTo 300 50", + "lineTo 275 0", + "lineTo 250 50", + "endFill", + "line 1 000000", + "moveTo 0 0", + "lineTo 50 0", + "lineTo 50 50", + "lineTo 0 50", + "lineTo 0 0", + "moveTo 50 50", + "lineTo 50 100", + "lineTo 0 100", + "lineTo 0 50" + ].join("\n"); +} + +private class GradientLineMorphShape extends TagDefineMorphShape +{ + public function new() + { + super(); + } + + override public function export(ratio:Float = 0):Void + { + exportHandler.beginShape(); + exportHandler.beginLines(); + exportHandler.lineStyle(1, 0, 1); + exportHandler.lineGradientStyle(LINEAR, [0xFF0000, 0x0000FF], [1, 1], [0, 255]); + exportHandler.moveTo(0, 0); + exportHandler.lineTo(50, 0); + exportHandler.endLines(); + exportHandler.endShape(); + } +} diff --git a/tests/src/tests/TagDefineFont2Test.hx b/tests/src/tests/TagDefineFont2Test.hx new file mode 100644 index 000000000..f3e42cb50 --- /dev/null +++ b/tests/src/tests/TagDefineFont2Test.hx @@ -0,0 +1,105 @@ +package tests; + +import swf.SWFData; +import swf.data.SWFShape; +import swf.data.SWFShapeRecordEnd; +import swf.tags.TagDefineFont2; +import swf.tags.TagDefineFont3; +import utest.Assert; +import utest.Test; + +class TagDefineFont2Test extends Test +{ + public function testFontWithGlyphsWithoutLayout():Void + { + // fonts used only by static text have glyphs, but no layout + var font = new TagDefineFont3(); + font.characterId = 1; + font.fontName = "Test"; + font.languageCode = 0; + font.hasLayout = false; + font.shiftJIS = false; + font.smallText = false; + font.ansi = false; + font.wideOffsets = false; + font.wideCodes = true; + font.italic = false; + font.bold = false; + font.glyphShapeTable.push(createGlyph()); + font.codeTable.push(65); + + var data = new SWFData(); + font.publish(data, 10); + + var parsed = new TagDefineFont3(); + var end = parse(data, parsed); + Assert.equals(end, data.position); + Assert.equals(1, parsed.glyphShapeTable.length); + Assert.equals(65, parsed.codeTable[0]); + } + + public function testFontWithoutGlyphsWithLayout():Void + { + // Flash writes a CodeTableOffset even when there are no glyphs + var data = createFontWithoutGlyphs(true); + + var parsed = new TagDefineFont3(); + var end = parse(data, parsed); + Assert.equals(end, data.position); + Assert.equals(0, parsed.glyphShapeTable.length); + Assert.equals(1000, parsed.ascent); + Assert.equals(200, parsed.descent); + Assert.equals(50, parsed.leading); + } + + public function testFontWithoutGlyphsWithoutLayout():Void + { + var data = createFontWithoutGlyphs(false); + + var parsed = new TagDefineFont2(); + var end = parse(data, parsed); + Assert.equals(end, data.position); + Assert.equals("Test", parsed.fontName); + } + + private static function parse(data:SWFData, font:TagDefineFont2):Int + { + data.position = 0; + var header = data.readTagHeader(); + var end = data.position + header.contentLength; + font.parse(data, header.contentLength, 10); + return end; + } + + private static function createFontWithoutGlyphs(hasLayout:Bool):SWFData + { + var body = new SWFData(); + body.writeUI16(1); + body.writeUI8(hasLayout ? 0x84 : 0x04); + body.writeLANGCODE(0); + body.writeUI8(4); + body.writeUTFBytes("Test"); + body.writeUI16(0); + body.writeUI16(4); // CodeTableOffset + if (hasLayout) + { + body.writeUI16(1000); + body.writeUI16(200); + body.writeSI16(50); + body.writeUI16(0); + } + + var data = new SWFData(); + data.writeTagHeader(hasLayout ? TagDefineFont3.TYPE : TagDefineFont2.TYPE, body.length); + data.writeBytes(body); + return data; + } + + private static function createGlyph():SWFShape + { + // an empty outline, the glyph contents do not matter here + var shape = new SWFShape(); + shape.records.push(new SWFShapeRecordEnd()); + return shape; + } +} diff --git a/tests/src/tests/TimelineTest.hx b/tests/src/tests/TimelineTest.hx new file mode 100644 index 000000000..6c61509a9 --- /dev/null +++ b/tests/src/tests/TimelineTest.hx @@ -0,0 +1,202 @@ +package tests; + +import openfl.display.DisplayObject; +import openfl.display.MovieClip; +import openfl.display.Shape; +import swf.exporters.animate.AnimateFrame; +import swf.exporters.animate.AnimateFrameObject; +import swf.exporters.animate.AnimateFrameObjectType; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.animate.AnimateShapeSymbol; +import swf.exporters.animate.AnimateSpriteSymbol; +import swf.exporters.animate.AnimateTimeline; +import swf.exporters.swflite.SWFLite; +import swf.exporters.swflite.ShapeSymbol; +import swf.exporters.swflite.SpriteSymbol; +import swf.exporters.swflite.timeline.Frame; +import swf.exporters.swflite.timeline.FrameObject; +import swf.exporters.swflite.timeline.FrameObjectType; +import swf.exporters.swflite.timeline.SymbolTimeline; +import utest.Assert; +import utest.Test; + +@:access(openfl.display.DisplayObject) +@:access(swf.exporters.animate) +@:access(swf.exporters.swflite) +class TimelineTest extends Test +{ + public function testTimelinesCompile():Void + { + // both timelines must compile on every target, including Flash + Assert.notNull(Type.getClassName(AnimateTimeline)); + Assert.notNull(Type.getClassName(SymbolTimeline)); + } + + #if !flash + public function testAnimateClippingCharacterWithoutTargetIsMask():Void + { + var clip = createAnimateClip([[animateObject(AnimateFrameObjectType.CREATE, 0, 1, 5)]]); + Assert.isTrue(clip.getChildAt(0).__isMask); + } + + public function testSWFLiteClippingCharacterWithoutTargetIsMask():Void + { + var clip = createSWFLiteClip([[swfLiteObject(FrameObjectType.CREATE, 0, 1, 5)]]); + Assert.isTrue(clip.getChildAt(0).__isMask); + } + + public function testAnimateClipDepthChangeCreatesNewInstance():Void + { + var clip = createAnimateClip([ + [animateObject(AnimateFrameObjectType.CREATE, 0, 1, 0)], + [animateObject(AnimateFrameObjectType.DESTROY, 0, 1, 0), animateObject(AnimateFrameObjectType.CREATE, 1, 1, 3)] + ]); + checkClipDepthChange(clip); + } + + public function testSWFLiteClipDepthChangeCreatesNewInstance():Void + { + var clip = createSWFLiteClip([ + [swfLiteObject(FrameObjectType.CREATE, 0, 1, 0)], + [swfLiteObject(FrameObjectType.DESTROY, 0, 1, 0), swfLiteObject(FrameObjectType.CREATE, 1, 1, 3)] + ]); + checkClipDepthChange(clip); + } + + public function testAnimateNamedChildrenAreAssignedPerClass():Void + { + // instance fields are cached by class, so each class must still get its own fields + var library = createAnimateLibrary(); + var symbolA = addAnimateSprite(library, 10, Type.getClassName(TimelineFieldClipA), [[animateObject(AnimateFrameObjectType.CREATE, 0, 1, 0, "first")]]); + var symbolB = addAnimateSprite(library, 11, Type.getClassName(TimelineFieldClipB), [[animateObject(AnimateFrameObjectType.CREATE, 0, 1, 0, "second")]]); + + var a1:TimelineFieldClipA = cast symbolA.__createObject(library); + var b:TimelineFieldClipB = cast symbolB.__createObject(library); + var a2:TimelineFieldClipA = cast symbolA.__createObject(library); + + Assert.notNull(a1.first); + Assert.notNull(a2.first); + Assert.notNull(b.second); + Assert.notEquals(a1.first, a2.first); + library.unload(); + } + + private static function checkClipDepthChange(clip:MovieClip):Void + { + clip.gotoAndStop(1); + var first = clip.getChildAt(0); + // __isMask is not initialized on display objects that never were masks + Assert.isTrue(first.__isMask != true); + + // the same character at the same depth, now with a clip depth, is a new mask instance + clip.gotoAndStop(2); + var second = clip.getChildAt(0); + Assert.notEquals(first, second); + Assert.isTrue(second.__isMask); + } + + private static function createAnimateLibrary():AnimateLibrary + { + var library = new AnimateLibrary("timeline-test", "timeline-test"); + library.frameRate = 24; + library.symbols = new Map(); + library.symbolsByClassName = new Map(); + library.bitmapSymbols = []; + + var shape = new AnimateShapeSymbol(); + shape.id = 1; + shape.commands = []; + library.symbols.set(shape.id, shape); + return library; + } + + private static function addAnimateSprite(library:AnimateLibrary, id:Int, className:String, frames:Array>):AnimateSpriteSymbol + { + var sprite = new AnimateSpriteSymbol(); + sprite.id = id; + sprite.className = className; + for (objects in frames) + { + var frame = new AnimateFrame(); + frame.objects = objects; + sprite.frames.push(frame); + } + library.symbols.set(id, sprite); + return sprite; + } + + private static function createAnimateClip(frames:Array>):MovieClip + { + var library = createAnimateLibrary(); + var clip:MovieClip = cast addAnimateSprite(library, 10, null, frames).__createObject(library); + library.unload(); + return clip; + } + + private static function animateObject(type:AnimateFrameObjectType, id:Int, symbol:Int, clipDepth:Int, name:String = null):AnimateFrameObject + { + var object = new AnimateFrameObject(); + object.type = type; + object.id = id; + object.symbol = symbol; + object.depth = 1; + object.clipDepth = clipDepth; + object.name = name; + return object; + } + + private static function createSWFLiteClip(frames:Array>):MovieClip + { + var swf = new SWFLite(); + swf.frameRate = 24; + + var shape = new ShapeSymbol(); + shape.id = 1; + shape.commands = []; + swf.symbols.set(shape.id, shape); + + var sprite = new SpriteSymbol(); + sprite.id = 10; + for (objects in frames) + { + var frame = new Frame(); + frame.objects = objects; + sprite.frames.push(frame); + } + swf.symbols.set(sprite.id, sprite); + + return cast sprite.__createObject(swf); + } + + private static function swfLiteObject(type:FrameObjectType, id:Int, symbol:Int, clipDepth:Int):FrameObject + { + var object = new FrameObject(); + object.type = type; + object.id = id; + object.symbol = symbol; + object.depth = 1; + object.clipDepth = clipDepth; + return object; + } + #end +} + +class TimelineFieldClipA extends MovieClip +{ + public var first:DisplayObject; + + public function new() + { + super(); + } +} + +class TimelineFieldClipB extends MovieClip +{ + public var second:DisplayObject; + + public function new() + { + super(); + } +} From fbc2c642aee46876902ac5aed3c121937621e12b Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 00:32:22 -0400 Subject: [PATCH 23/41] Tests: add tests for the command-line tools, and run them in CI The tools need sys and hxp, so they run as a separate neko suite in tests/tools. They cover generated class templates and paths, the classes compiled with generate="true", the generated class caches, the root symbol, and linked sound and line gradient export. --- .github/workflows/main.yml | 35 +++ tests/tools/src/ToolsTestMain.hx | 8 + .../src/tools/AnimateLibraryExporterTest.hx | 226 ++++++++++++++++++ tests/tools/src/tools/ToolsTest.hx | 148 ++++++++++++ tests/tools/tools.hxml | 14 ++ 5 files changed, 431 insertions(+) create mode 100644 tests/tools/src/ToolsTestMain.hx create mode 100644 tests/tools/src/tools/AnimateLibraryExporterTest.hx create mode 100644 tests/tools/src/tools/ToolsTest.hx create mode 100644 tests/tools/tools.hxml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5e2e92862..3a7e6c33f 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -126,6 +126,41 @@ jobs: run: | haxelib run openfl test neko + unit-test-tools: + strategy: + matrix: + haxe-version: [4.2.5, 4.3.6] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: krdlab/setup-haxe@v2 + with: + haxe-version: ${{ matrix.haxe-version }} + + - name: Set HAXEPATH + run: | + echo "HAXEPATH=$HAXE_STD_PATH/.." >> $GITHUB_ENV + + - name: Install Haxe dependencies + run: | + haxelib install format --quiet + haxelib install lime --quiet + haxelib install openfl --quiet + haxelib install hxp --quiet + haxelib install utest --quiet + haxelib dev swf ${{ github.workspace }} + + - name: Rebuild swf run.n + run: | + haxe rebuild.hxml + + - name: Run tools tests + working-directory: tests/tools + run: | + haxe tools.hxml + neko bin/tools.n + unit-test-hashlink: strategy: matrix: diff --git a/tests/tools/src/ToolsTestMain.hx b/tests/tools/src/ToolsTestMain.hx new file mode 100644 index 000000000..c1f89cacf --- /dev/null +++ b/tests/tools/src/ToolsTestMain.hx @@ -0,0 +1,8 @@ +// Tests for the command-line tools, which need sys and hxp, so they are separate from the OpenFL tests +class ToolsTestMain +{ + public static function main():Void + { + utest.UTest.run([new tools.AnimateLibraryExporterTest(), new tools.ToolsTest()]); + } +} diff --git a/tests/tools/src/tools/AnimateLibraryExporterTest.hx b/tests/tools/src/tools/AnimateLibraryExporterTest.hx new file mode 100644 index 000000000..541c6ff93 --- /dev/null +++ b/tests/tools/src/tools/AnimateLibraryExporterTest.hx @@ -0,0 +1,226 @@ +package tools; + +import haxe.Json; +import haxe.io.Bytes; +import haxe.zip.Reader; +import hxp.Path; +import lime.tools.Asset; +import openfl.utils.ByteArray; +import swf.SWFRoot; +import swf.data.consts.SoundCompression; +import swf.data.consts.SoundRate; +import swf.data.consts.SoundSize; +import swf.data.consts.SoundType; +import swf.exporters.AnimateLibraryExporter; +import swf.tags.TagDefineShape; +import swf.tags.TagDefineSound; +import sys.FileSystem; +import sys.io.File; +import tests.SWFBuilder; +import tests.ShapeBuilder; +import utest.Assert; +import utest.Test; + +@:access(swf.exporters.AnimateLibraryExporter) +class AnimateLibraryExporterTest extends Test +{ + public function testGeneratedClassesAreTemplates():Void + { + var exporter = export(new SWFBuilder().add(SWFBuilder.createSprite(1)).link(1, "Clip").parse()); + + var output:Array = []; + Assert.same(["Clip"], exporter.generateClasses("generated", output)); + Assert.equals(1, output.length); + Assert.isTrue(output[0].type == lime.tools.AssetType.TEMPLATE); + } + + public function testIncludedClassesOnlyDependOnTheLibrary():Void + { + // Clip has a child field of type Child, so it depends on Child and on Child's base class + Assert.same(["Child", "Clip"], getIncludedClasses(null)); + Assert.same([], getIncludedClasses("com.example.Missing")); + Assert.same(["Child", "Clip"], getIncludedClasses("Clip")); + } + + public function testSymbolClassZeroIsRoot():Void + { + // the document class is linked to character 0, and is not always named *_fla.MainTimeline + var exporter = export(new SWFBuilder().link(0, "MainDocument").parse()); + Assert.equals("MainDocument", exporter.libraryData.root.className); + } + + public function testLineGradientStyleIsSerialized():Void + { + var tag = new TagDefineShape(); + tag.characterId = 1; + tag.shapes = new ShapeBuilder() + .addGradientLine(20, [0xFFFF0000, 0xFF0000FF], [0, 255]) + .move(0, 0, -1, -1, 1) + .lineTo(1000, 0) + .end(); + + var exporter = export(new SWFBuilder().parse()); + var symbol = exporter.addShape(tag); + var commands:Array = symbol.commands; + + // LINE_GRADIENT_STYLE, type, colors, alphas, ratios, matrix, spread, interpolation, focal point + var index = -1; + for (i in 0...commands.length - 8) + { + if (commands[i] == 9 && Std.isOfType(commands[i + 2], Array)) + { + index = i; + break; + } + } + Assert.isTrue(index > -1); + Assert.same([0xFF0000, 0x0000FF], commands[index + 2]); + Assert.same([0, 255], commands[index + 4]); + } + + public function testLinkedSoundExport():Void + { + var pcm16 = createPCM(441, 2); + var pcm8 = createPCM(1001, 1); + var frames = createMP3Frames(); + var mp3 = new ByteArray(); + // an empty ID3v2 tag, which DefineSound does not keep + for (value in [0x49, 0x44, 0x33, 3, 0, 0, 0, 0, 0, 0]) + { + mp3.writeByte(value); + } + mp3.writeBytes(frames); + + var path = tempPath("sounds.zip"); + new AnimateLibraryExporter(new SWFBuilder() + .add(TagDefineSound.create(1, SoundCompression.UNCOMPRESSED_LITTLE_ENDIAN, SoundRate.KHZ_11, SoundSize.BIT_16, SoundType.MONO, 441, pcm16)) + .add(TagDefineSound.create(2, SoundCompression.UNCOMPRESSED_NATIVE_ENDIAN, SoundRate.KHZ_22, SoundSize.BIT_8, SoundType.STEREO, 500, pcm8)) + .add(TagDefineSound.createWithMP3(3, mp3)) + .add(TagDefineSound.create(4, SoundCompression.ADPCM, SoundRate.KHZ_22, SoundSize.BIT_16, SoundType.MONO, 4, createPCM(4, 1))) + .add(TagDefineSound.create(5, SoundCompression.SPEEX, SoundRate.KHZ_5, SoundSize.BIT_16, SoundType.MONO, 4, createPCM(4, 1))) + .link(1, "TestSound") + .link(2, "TestSound8") + .link(3, "TestMp3") + .link(4, "TestAdpcm") + .link(5, "TestSpeex") + .parse(), path); + + var entries = readZip(path); + checkWAV(entries.get("sounds/1.wav"), 1, 11025, 16, pcm16.length); + checkWAV(entries.get("sounds/2.wav"), 2, 22050, 8, pcm8.length); + var expectedMP3 = Bytes.alloc(frames.length); + for (i in 0...frames.length) + { + expectedMP3.set(i, frames[i]); + } + Assert.equals(0, entries.get("sounds/3.mp3").compare(expectedMP3)); + Assert.isFalse(entries.exists("sounds/4.wav")); + + var symbols = new Map(); + for (symbol in (Json.parse(entries.get("data.json").toString()).symbols : Array)) + { + if (symbol != null) symbols.set(symbol.id, symbol); + } + Assert.equals("TestSound", symbols.get(1).className); + Assert.equals("sounds/1.wav", symbols.get(1).path); + Assert.equals(7, symbols.get(1).type); + Assert.equals("TestMp3", symbols.get(3).className); + Assert.isFalse(symbols.exists(4)); + Assert.isFalse(symbols.exists(5)); + + var soundAssets = []; + for (asset in (Json.parse(entries.get("library.json").toString()).assets : Array)) + { + if (asset.type == "SOUND") soundAssets.push(asset.path); + } + soundAssets.sort(Reflect.compare); + Assert.same(["sounds/1.wav", "sounds/2.wav", "sounds/3.mp3"], soundAssets); + } + + private static function getIncludedClasses(childBaseClassName:String):Array + { + var exporter = export(new SWFBuilder().parse()); + exporter.libraryData.symbols = new Map(); + exporter.libraryData.symbols.set(1, {type: 5, id: 1, className: "Child", baseClassName: childBaseClassName, frames: []}); + exporter.libraryData.symbols.set(2, { + type: 5, + id: 2, + className: "Clip", + baseClassName: null, + frames: [{objects: [{name: "child", symbol: 1}]}] + }); + + var included = []; + exporter.generateClasses("generated", [], "", included); + included.sort(Reflect.compare); + return included; + } + + private static function export(swf:SWFRoot, path:String = null):AnimateLibraryExporter + { + return new AnimateLibraryExporter(swf, path != null ? path : tempPath("library.zip")); + } + + private static function checkWAV(wav:Bytes, channels:Int, sampleRate:Int, bitsPerSample:Int, dataLength:Int):Void + { + Assert.equals("RIFF", wav.getString(0, 4)); + // RIFF chunks are padded to an even length + Assert.equals(44 + dataLength + dataLength % 2, wav.length); + Assert.equals(wav.length - 8, wav.getInt32(4)); + Assert.equals("WAVE", wav.getString(8, 4)); + Assert.equals(1, wav.getUInt16(20)); + Assert.equals(channels, wav.getUInt16(22)); + Assert.equals(sampleRate, wav.getInt32(24)); + Assert.equals(sampleRate * channels * Std.int(bitsPerSample / 8), wav.getInt32(28)); + Assert.equals(bitsPerSample, wav.getUInt16(34)); + Assert.equals("data", wav.getString(36, 4)); + Assert.equals(dataLength, wav.getInt32(40)); + } + + private static function createMP3Frames():ByteArray + { + // two silent MPEG-1 Layer III frames at 128 kbit/s and 44.1 kHz, 417 bytes each + var frames = new ByteArray(); + for (frame in 0...2) + { + for (value in [0xFF, 0xFB, 0x90, 0x00]) + { + frames.writeByte(value); + } + for (i in 0...413) + { + frames.writeByte(0); + } + } + return frames; + } + + private static function createPCM(length:Int, seed:Int):ByteArray + { + var data = new ByteArray(); + for (i in 0...length) + { + data.writeByte((i * seed) & 0xFF); + } + return data; + } + + private static function readZip(path:String):Map + { + var entries = new Map(); + var input = File.read(path, true); + for (entry in Reader.readZip(input)) + { + entries.set(entry.fileName, entry.compressed ? Reader.unzip(entry) : entry.data); + } + input.close(); + return entries; + } + + public static function tempPath(name:String):String + { + var directory = Path.combine(Path.directory(Sys.programPath()), "tmp"); + if (!FileSystem.exists(directory)) FileSystem.createDirectory(directory); + return Path.combine(directory, name); + } +} diff --git a/tests/tools/src/tools/ToolsTest.hx b/tests/tools/src/tools/ToolsTest.hx new file mode 100644 index 000000000..73c9aaffb --- /dev/null +++ b/tests/tools/src/tools/ToolsTest.hx @@ -0,0 +1,148 @@ +package tools; + +import haxe.Json; +import hxp.Path; +import hxp.System; +import lime.tools.Asset; +import lime.tools.AssetType; +import lime.tools.HXProject; +import lime.tools.Library; +import lime.tools.Platform; +import sys.FileSystem; +import sys.io.File; +import tests.SWFBuilder; +import utest.Assert; +import utest.Test; + +@:access(Tools) +class ToolsTest extends Test +{ + public function testFlashClassPaths():Void + { + var swfPath = writeSWF(createDirectory("flash-paths"), new SWFBuilder() + .add(SWFBuilder.createSprite(1)) + .add(SWFBuilder.createSprite(2)) + .link(1, "SkyPandaTiles_fla.Clip") + .link(2, "Plain")); + + // Haxe packages start with a lowercase letter, and the prefix is only added once + Assert.same(["../haxe/_generated/Plain.hx", "../haxe/_generated/skyPandaTiles_fla/Clip.hx"], getFlashClassPaths(swfPath, "")); + Assert.same(["../haxe/_generated/LibPlain.hx", "../haxe/_generated/skyPandaTiles_fla/LibClip.hx"], getFlashClassPaths(swfPath, "lib")); + } + + public function testFlashLibraryCache():Void + { + var directory = createDirectory("flash-cache"); + var project = createProject(Platform.FLASH, writeSWF(directory, createClipSWF()), true); + Tools.targetDirectory = directory; + + var output = Tools.processLibraries(project); + Assert.isTrue(output.haxeflags.indexOf("Clip") > -1); + Assert.notNull(findTemplate(output, "Clip.hx")); + + markCachedTemplates(directory); + var cached = Tools.processLibraries(project); + Assert.isTrue(cached.haxeflags.indexOf("Clip") > -1); + Assert.equals("// cached", findTemplate(cached, "Clip.hx").data); + + // a different prefix makes the cache out of date + project.libraries[0].prefix = "lib"; + var renamed = Tools.processLibraries(project); + Assert.isTrue(renamed.haxeflags.indexOf("LibClip") > -1); + Assert.notNull(findTemplate(renamed, "LibClip.hx")); + } + + public function testAnimateGeneratedClassesAreCompiled():Void + { + var directory = createDirectory("animate-generate"); + var project = createProject(Platform.HTML5, writeSWF(directory, createClipSWF()), true); + Tools.targetDirectory = directory; + + var output = Tools.processLibraries(project); + Assert.isTrue(output.haxeflags.indexOf("Clip") > -1); + Assert.notNull(findTemplate(output, "Clip.hx")); + + // templates come back from the cache, otherwise Lime would delete them as stale files + markCachedTemplates(directory); + var cached = Tools.processLibraries(project); + Assert.isTrue(cached.haxeflags.indexOf("Clip") > -1); + Assert.equals("// cached", findTemplate(cached, "Clip.hx").data); + + // classes are only compiled when generate="true" + project.libraries[0].generate = null; + var unset = Tools.processLibraries(project); + Assert.equals(-1, unset.haxeflags.indexOf("Clip")); + Assert.notNull(findTemplate(unset, "Clip.hx")); + + project.libraries[0].generate = false; + var disabled = Tools.processLibraries(project); + Assert.equals(-1, disabled.haxeflags.indexOf("Clip")); + Assert.isNull(findTemplate(disabled, "Clip.hx")); + } + + private static function createClipSWF():SWFBuilder + { + return new SWFBuilder().add(SWFBuilder.createSprite(1)).link(1, "Clip"); + } + + private static function createProject(target:Platform, swfPath:String, generate:Null):HXProject + { + var project = new HXProject(); + project.target = target; + project.libraries.push(new Library(swfPath, "lib", "swf", true, false, generate, "")); + return project; + } + + private static function createDirectory(name:String):String + { + var directory = AnimateLibraryExporterTest.tempPath(name); + if (FileSystem.exists(directory)) System.removeDirectory(directory); + System.mkdir(directory); + return directory; + } + + private static function findTemplate(project:HXProject, fileName:String):Asset + { + for (asset in project.assets) + { + if (asset.type == AssetType.TEMPLATE && Path.withoutDirectory(asset.targetPath) == fileName) + { + return asset; + } + } + return null; + } + + private static function getFlashClassPaths(swfPath:String, prefix:String):Array + { + var output = new HXProject(); + Tools.generateSWFClasses(new HXProject(), output, new Asset(swfPath, "library.swf", AssetType.BINARY), prefix); + + var paths = [for (asset in output.assets) StringTools.replace(asset.targetPath, "\\", "/")]; + paths.sort(Reflect.compare); + return paths; + } + + private static function markCachedTemplates(directory:String):Void + { + // so the next build can tell whether the templates came from the cache + var cacheFile = Path.combine(directory, "obj/libraries/lib.classes.json"); + Assert.isTrue(FileSystem.exists(cacheFile)); + + var cache:Dynamic = Json.parse(File.getContent(cacheFile)); + for (template in (cache.templates : Array)) + { + template.data = "// cached"; + } + File.saveContent(cacheFile, Json.stringify(cache)); + } + + private static function writeSWF(directory:String, swf:SWFBuilder):String + { + var path = Path.combine(directory, "library.swf"); + File.saveBytes(path, swf.build()); + // caches are only used when they are newer than the SWF file + Sys.sleep(1.1); + return path; + } +} diff --git a/tests/tools/tools.hxml b/tests/tools/tools.hxml new file mode 100644 index 000000000..e207ef4bf --- /dev/null +++ b/tests/tools/tools.hxml @@ -0,0 +1,14 @@ +-cp src +-cp ../src +-cp ../../src +-cp ../../scripts +-lib openfl +-lib lime +-lib hxp +-lib format +-lib utest +-D lime-cairo +-D lime-cffi +-D optional-cffi +-main ToolsTestMain +-neko bin/tools.n From d547b80898caa0bb358040a935fd52a23ec2c0ba Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Mon, 26 Oct 2026 21:09:07 -0400 Subject: [PATCH 24/41] prepare for 3.5.0 --- CHANGELOG.md | 29 +++++++++++++++++++++++++++++ haxelib.json | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c954b8e8..b0138917b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,32 @@ +3.5.0 (10/26/2026) +------------------ + +* Added `DisplayObject.metaData` support for Animate libraries +* Added `lineGradientStyle` support for SWF shapes in Animate, SWFLite and runtime shapes +* Added caching of generated classes for Flash and AIR SWF libraries +* Added export of linked MP3 and uncompressed sounds in Animate libraries (MP3 on native targets requires Lime 8.4 or newer) +* Improved `generate="true"` on Animate libraries to compile the generated classes, so symbols are created as their linkage class +* Improved Animate shape memory usage by keeping commands compact until a shape is first drawn +* Improved SWF exporter performance for shapes with many drawing commands +* Improved timeline performance by caching instance field lists per class +* Changed `AnimateShapeSymbol.commands` to be `null` for shapes loaded from an Animate library +* Fixed Animate and SWFLite exports of SWF files without ABC or `SymbolClass` tags, such as AS2 SWF files using `ExportAssets` +* Fixed `SWFLibrary` failing to load non-embedded SWF files +* Fixed processing SWF files with linked Speex sounds +* Fixed generated Animate classes using another library's symbol when several libraries export the same class +* Fixed the document class not being applied to the root timeline when it is not named `*_fla.MainTimeline` +* Fixed clipping characters being displayed when nothing is in their mask range +* Fixed reused timeline instances keeping an outdated `clipDepth` +* Fixed Animate `blendMode` being set to the string `"null"` +* Fixed `clippingLayer` being set on the Flash target +* Fixed SWFLite timelines failing to compile on the Flash target +* Fixed `DefineFont2` and `DefineFont3` parsing reading past the tag for fonts without layout +* Fixed generated Flash/AIR classes being written to the wrong package directory +* Fixed C++ compile error in `AnimateLibraryExporter` class generation +* Fixed Animate libraries created without a UUID being registered under a `null` key +* Fixed processed Animate ZIP files not being closed after writing + + 3.4.0 (11/11/2025) ------------------ diff --git a/haxelib.json b/haxelib.json index 89b74306e..f1ac9d6a8 100644 --- a/haxelib.json +++ b/haxelib.json @@ -4,8 +4,8 @@ "license": "MIT", "tags": [ "openfl", "swf", "animation", "flash", "assets", "swc" ], "description": "Provides support for parsing and displaying SWF content", - "version": "3.4.0", - "releasenote": "Fix duplicate scale9Grid graphics, improved clipping layers, new filters", + "version": "3.5.0", + "releasenote": "Line gradient styles, lower Animate shape memory use, clipping and exporter fixes", "contributors": [ "singmajesty", "bowlerhat", From 15d738599765e106e127347de80f4b181ca06ca9 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 03:55:56 -0400 Subject: [PATCH 25/41] TagDefineFont2: compare remaining bytes as Int, which Haxe 3 requires on Flash On Flash, ByteArray.position is UInt, and Haxe 3 does not allow comparing it with an Int. --- src/swf/tags/TagDefineFont2.hx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/swf/tags/TagDefineFont2.hx b/src/swf/tags/TagDefineFont2.hx index 8a0a71acc..e190fda1e 100644 --- a/src/swf/tags/TagDefineFont2.hx +++ b/src/swf/tags/TagDefineFont2.hx @@ -83,11 +83,15 @@ class TagDefineFont2 extends TagDefineFont implements IDefinitionTag codeTable.push(wideCodes ? data.readUI16() : data.readUI8()); } } - else if (length - (data.position - start) > (hasLayout ? 8 : 0)) + else { // Flash may write a CodeTableOffset even when there are no glyphs. // Without glyphs to kern, the layout that follows is 8 bytes. - data.skipBytes(wideOffsets ? 4 : 2); + var remaining:Int = length - (data.position - start); + if (remaining > (hasLayout ? 8 : 0)) + { + data.skipBytes(wideOffsets ? 4 : 2); + } } if (hasLayout) { From 306025a8b8bcbea29f049240a37beeb203a7b727 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 04:32:44 -0400 Subject: [PATCH 26/41] AnimateLibrary: find generated classes with a prefix Generated classes are named with the library prefix, but symbols looked up their class by the linkage name, so they were created as a plain MovieClip. Generated MovieClip and SimpleButton classes now register themselves by library and symbol ID, and symbols use that class first. A generated class whose base class comes from the same library now extends that base class's generated name, with the same prefix. --- src/swf/exporters/AnimateLibraryExporter.hx | 14 ++++++ .../exporters/animate/AnimateButtonSymbol.hx | 21 +++++---- src/swf/exporters/animate/AnimateLibrary.hx | 15 +++++++ .../exporters/animate/AnimateSpriteSymbol.hx | 5 ++- templates/animate/MovieClip.mtt | 5 +++ templates/animate/SimpleButton.mtt | 5 +++ tests/src/tests/AnimateGeneratedClassTest.hx | 19 +++++++- tests/src/tests/GeneratedTestClip.hx | 5 +++ tests/src/tests/LibPrefixedTestClip.hx | 26 +++++++++++ .../src/tools/AnimateLibraryExporterTest.hx | 44 ++++++++++++++++--- 10 files changed, 137 insertions(+), 22 deletions(-) create mode 100644 tests/src/tests/LibPrefixedTestClip.hx diff --git a/src/swf/exporters/AnimateLibraryExporter.hx b/src/swf/exporters/AnimateLibraryExporter.hx index 57663bf43..435be148e 100644 --- a/src/swf/exporters/AnimateLibraryExporter.hx +++ b/src/swf/exporters/AnimateLibraryExporter.hx @@ -1214,6 +1214,15 @@ class AnimateLibraryExporter var generatedClasses = []; var classDependencies = new Map>(); + var libraryClassNames = new Map(); + for (symbol in libraryData.symbols) + { + if (Reflect.hasField(symbol, "className") && symbol.className != null) + { + libraryClassNames.set(symbol.className, true); + } + } + for (symbolID in libraryData.symbols.keys()) { var symbol = libraryData.symbols.get(symbolID); @@ -1232,6 +1241,11 @@ class AnimateLibraryExporter if (Reflect.hasField(symbol, "baseClassName")) { baseClassName = symbol.baseClassName; + // a base class from this library is generated too, with the same prefix + if (baseClassName != null && libraryClassNames.exists(baseClassName)) + { + baseClassName = SymbolUtils.formatClassName(baseClassName, prefix); + } } } else if (type == BUTTON) diff --git a/src/swf/exporters/animate/AnimateButtonSymbol.hx b/src/swf/exporters/animate/AnimateButtonSymbol.hx index 1c43e3e01..7f6947fce 100644 --- a/src/swf/exporters/animate/AnimateButtonSymbol.hx +++ b/src/swf/exporters/animate/AnimateButtonSymbol.hx @@ -9,6 +9,7 @@ import openfl.display.SimpleButton; @:noDebug #end @:access(openfl.display.SimpleButton) +@:access(swf.exporters.animate.AnimateLibrary) class AnimateButtonSymbol extends AnimateSymbol { public var downState:AnimateSpriteSymbol; @@ -59,18 +60,16 @@ class AnimateButtonSymbol extends AnimateSymbol } #end - if (className != null) + var symbolType = library.__getGeneratedClass(id); + + if (symbolType == null && className != null) + { + symbolType = Type.resolveClass(SymbolUtils.formatClassName(className)); + } + + if (symbolType != null) { - var symbolType = Type.resolveClass(SymbolUtils.formatClassName(className)); - - if (symbolType != null) - { - simpleButton = __createInstance(library, symbolType); - } - else - { - // Log.warn ("Could not resolve class \"" + symbol.className + "\""); - } + simpleButton = __createInstance(library, symbolType); } if (simpleButton == null) diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index c02a1b33b..9e9a16f05 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -54,6 +54,8 @@ import openfl.filters.GlowFilter; ]; #end + // Generated classes register here from __init__, which can run before static initializers + @:noCompletion private static var __generatedClasses:Map>; private static var instances:Map = new Map(); private var alphaCheck:Map; @@ -539,6 +541,12 @@ import openfl.filters.GlowFilter; } #end + // Generated class names can have a prefix, so symbols find their class by library and symbol ID + @:noCompletion private function __getGeneratedClass(symbolID:Int):Class + { + return __generatedClasses != null ? __generatedClasses.get(instanceID + ":" + symbolID) : null; + } + #if lime private function __getSoundID(id:String):String { @@ -833,6 +841,13 @@ import openfl.filters.GlowFilter; return symbol; } + // Called from __init__ by generated classes that are compiled + @:noCompletion private static function __registerGeneratedClass(uuid:String, symbolID:Int, type:Class):Void + { + if (__generatedClasses == null) __generatedClasses = new Map(); + __generatedClasses.set(uuid + ":" + symbolID, type); + } + private inline function __pixel(value:Int):Float { return value / 20; diff --git a/src/swf/exporters/animate/AnimateSpriteSymbol.hx b/src/swf/exporters/animate/AnimateSpriteSymbol.hx index b475f0499..3fe6064ff 100644 --- a/src/swf/exporters/animate/AnimateSpriteSymbol.hx +++ b/src/swf/exporters/animate/AnimateSpriteSymbol.hx @@ -10,6 +10,7 @@ import openfl.geom.Rectangle; @:fileXml('tags="haxe,release"') @:noDebug #end +@:access(swf.exporters.animate.AnimateLibrary) @:access(openfl.display.Sprite) class AnimateSpriteSymbol extends AnimateSymbol { @@ -57,9 +58,9 @@ class AnimateSpriteSymbol extends AnimateSymbol } #end - var symbolType = null; + var symbolType = library.__getGeneratedClass(id); - if (className != null) + if (symbolType == null && className != null) { symbolType = Type.resolveClass(SymbolUtils.formatClassName(className)); diff --git a/templates/animate/MovieClip.mtt b/templates/animate/MovieClip.mtt index 813070b16..b1cf6bef8 100644 --- a/templates/animate/MovieClip.mtt +++ b/templates/animate/MovieClip.mtt @@ -7,6 +7,11 @@ class ::CLASS_NAME:: extends ::if BASE_CLASS_NAME::::BASE_CLASS_NAME::::else::#i ::foreach CLASS_PROPERTIES::@:keep ::if hidden::@:noCompletion @:dox(hide) ::end::public var ::name::(default, null):::type::; ::end:: + @:noCompletion private static function __init__():Void + { + swf.exporters.animate.AnimateLibrary.__registerGeneratedClass("::UUID::", ::SYMBOL_ID::, ::CLASS_NAME::); + } + public function new() { if (!swf.exporters.animate.AnimateSymbol.__initPending(::CLASS_NAME::)) diff --git a/templates/animate/SimpleButton.mtt b/templates/animate/SimpleButton.mtt index 9bdd4c22b..d8002a6e0 100644 --- a/templates/animate/SimpleButton.mtt +++ b/templates/animate/SimpleButton.mtt @@ -4,6 +4,11 @@ package ::PACKAGE_NAME::; class ::CLASS_NAME:: extends ::if BASE_CLASS_NAME::::BASE_CLASS_NAME::::else::#if flash flash.display.SimpleButton.SimpleButton2 #else openfl.display.SimpleButton #end::end:: { + @:noCompletion private static function __init__():Void + { + swf.exporters.animate.AnimateLibrary.__registerGeneratedClass("::UUID::", ::SYMBOL_ID::, ::CLASS_NAME::); + } + public function new() { if (!swf.exporters.animate.AnimateSymbol.__initPending(::CLASS_NAME::)) diff --git a/tests/src/tests/AnimateGeneratedClassTest.hx b/tests/src/tests/AnimateGeneratedClassTest.hx index 082ce25c4..2d77c77bf 100644 --- a/tests/src/tests/AnimateGeneratedClassTest.hx +++ b/tests/src/tests/AnimateGeneratedClassTest.hx @@ -31,13 +31,28 @@ class AnimateGeneratedClassTest extends Test Assert.same(new Rectangle(1, 2, 3, 4), clip.scale9Grid); Assert.isNull(AnimateSymbol.__pendingSymbol); } + + public function testPrefixedGeneratedClass():Void + { + // with a prefix, the generated class is not named after the linkage, so it is found by library and symbol + var library = createLibrary(LibPrefixedTestClip.UUID, new Rectangle(1, 2, 3, 4), "tests.PrefixedTestClip"); + var clip:MovieClip = cast library.symbols.get(1).__createObject(library); + Assert.equals(Type.getClassName(LibPrefixedTestClip), Type.getClassName(Type.getClass(clip))); + Assert.same(new Rectangle(1, 2, 3, 4), clip.scale9Grid); + + // another library with the same linkage did not generate this class + var other = createLibrary("generated-prefix-test-other", new Rectangle(5, 6, 7, 8), "tests.PrefixedTestClip"); + var otherClip:MovieClip = cast other.symbols.get(1).__createObject(other); + Assert.equals(Type.getClassName(MovieClip), Type.getClassName(Type.getClass(otherClip))); + Assert.same(new Rectangle(5, 6, 7, 8), otherClip.scale9Grid); + } #end - private static function createLibrary(uuid:String, scale9Grid:Rectangle):AnimateLibrary + private static function createLibrary(uuid:String, scale9Grid:Rectangle, className:String = null):AnimateLibrary { var symbol = new AnimateSpriteSymbol(); symbol.id = 1; - symbol.className = Type.getClassName(GeneratedTestClip); + symbol.className = className != null ? className : Type.getClassName(GeneratedTestClip); symbol.frames.push(new AnimateFrame()); symbol.scale9Grid = scale9Grid; diff --git a/tests/src/tests/GeneratedTestClip.hx b/tests/src/tests/GeneratedTestClip.hx index bdb83164a..4422c733f 100644 --- a/tests/src/tests/GeneratedTestClip.hx +++ b/tests/src/tests/GeneratedTestClip.hx @@ -6,6 +6,11 @@ class GeneratedTestClip extends openfl.display.MovieClip { public static inline var UUID = "generated-class-test-a"; + @:noCompletion private static function __init__():Void + { + swf.exporters.animate.AnimateLibrary.__registerGeneratedClass(UUID, 1, GeneratedTestClip); + } + public function new() { if (!swf.exporters.animate.AnimateSymbol.__initPending(GeneratedTestClip)) diff --git a/tests/src/tests/LibPrefixedTestClip.hx b/tests/src/tests/LibPrefixedTestClip.hx new file mode 100644 index 000000000..d8ac81cbc --- /dev/null +++ b/tests/src/tests/LibPrefixedTestClip.hx @@ -0,0 +1,26 @@ +package tests; + +// Matches a class generated from templates/animate/MovieClip.mtt with the prefix "Lib", +// for a symbol linked as tests.PrefixedTestClip +@:access(swf.exporters.animate) +class LibPrefixedTestClip extends openfl.display.MovieClip +{ + public static inline var UUID = "generated-prefix-test"; + + @:noCompletion private static function __init__():Void + { + swf.exporters.animate.AnimateLibrary.__registerGeneratedClass(UUID, 1, LibPrefixedTestClip); + } + + public function new() + { + if (!swf.exporters.animate.AnimateSymbol.__initPending(LibPrefixedTestClip)) + { + var library = swf.exporters.animate.AnimateLibrary.get(UUID); + var symbol = library.symbols.get(1); + symbol.__init(library); + } + + super(); + } +} diff --git a/tests/tools/src/tools/AnimateLibraryExporterTest.hx b/tests/tools/src/tools/AnimateLibraryExporterTest.hx index 541c6ff93..95976b45d 100644 --- a/tests/tools/src/tools/AnimateLibraryExporterTest.hx +++ b/tests/tools/src/tools/AnimateLibraryExporterTest.hx @@ -42,6 +42,28 @@ class AnimateLibraryExporterTest extends Test Assert.same(["Child", "Clip"], getIncludedClasses("Clip")); } + public function testPrefixedGeneratedClasses():Void + { + var exporter = createChildAndClip("Child"); + var output:Array = []; + var included = []; + var generated = exporter.generateClasses("generated", output, "lib", included); + generated.sort(Reflect.compare); + included.sort(Reflect.compare); + Assert.same(["LibChild", "LibClip"], generated); + Assert.same(["LibChild", "LibClip"], included); + + var clip:String = null; + for (asset in output) + { + if (Path.withoutDirectory(asset.targetPath) == "LibClip.hx") clip = asset.data; + } + // the base class from this library is generated with the same prefix + Assert.isTrue(clip.indexOf("class LibClip extends LibChild") > -1); + // generated classes register themselves, since their names do not match the linkage + Assert.isTrue(clip.indexOf('__registerGeneratedClass("' + exporter.libraryData.uuid + '", 2, LibClip)') > -1); + } + public function testSymbolClassZeroIsRoot():Void { // the document class is linked to character 0, and is not always named *_fla.MainTimeline @@ -138,22 +160,30 @@ class AnimateLibraryExporterTest extends Test } private static function getIncludedClasses(childBaseClassName:String):Array + { + var exporter = createChildAndClip(null); + exporter.libraryData.symbols.get(1).baseClassName = childBaseClassName; + + var included = []; + exporter.generateClasses("generated", [], "", included); + included.sort(Reflect.compare); + return included; + } + + // Clip has a child field of type Child + private static function createChildAndClip(clipBaseClassName:String):AnimateLibraryExporter { var exporter = export(new SWFBuilder().parse()); exporter.libraryData.symbols = new Map(); - exporter.libraryData.symbols.set(1, {type: 5, id: 1, className: "Child", baseClassName: childBaseClassName, frames: []}); + exporter.libraryData.symbols.set(1, {type: 5, id: 1, className: "Child", baseClassName: null, frames: []}); exporter.libraryData.symbols.set(2, { type: 5, id: 2, className: "Clip", - baseClassName: null, + baseClassName: clipBaseClassName, frames: [{objects: [{name: "child", symbol: 1}]}] }); - - var included = []; - exporter.generateClasses("generated", [], "", included); - included.sort(Reflect.compare); - return included; + return exporter; } private static function export(swf:SWFRoot, path:String = null):AnimateLibraryExporter From bb19aca1f3b4f90d265afb56d9e4e5ef1da671d3 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 04:33:28 -0400 Subject: [PATCH 27/41] Tools: show the swf version in the command-line banner Haxelib.getVersion() without an argument returns the version of Lime. The version now comes from the haxelib.json next to run.n. --- scripts/Tools.hx | 21 +++++++++++++++++++-- tests/tools/src/tools/ToolsTest.hx | 9 +++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/scripts/Tools.hx b/scripts/Tools.hx index 7ebd7892e..ea686b9f9 100644 --- a/scripts/Tools.hx +++ b/scripts/Tools.hx @@ -737,8 +737,8 @@ class Tools // } // else { - var version = Haxelib.getVersion(); - if (version == null) version = "0.0.0"; + // Haxelib.getVersion() without an argument is the version of Lime + var version = getVersion(Path.directory(Sys.programPath())); Log.println("\x1b[36;1mSWF Command-Line Tools (" + version + ")\x1b[0m"); } @@ -761,6 +761,23 @@ class Tools } } + private static function getVersion(directory:String):String + { + var path = Path.combine(directory, "haxelib.json"); + + if (FileSystem.exists(path)) + { + try + { + var version:String = Json.parse(File.getContent(path)).version; + if (version != null) return version; + } + catch (e:Dynamic) {} + } + + return "0.0.0"; + } + private static function isCacheCurrent(cacheFile:String, sourcePath:String):Bool { if (!FileSystem.exists(cacheFile)) return false; diff --git a/tests/tools/src/tools/ToolsTest.hx b/tests/tools/src/tools/ToolsTest.hx index 73c9aaffb..476b1395f 100644 --- a/tests/tools/src/tools/ToolsTest.hx +++ b/tests/tools/src/tools/ToolsTest.hx @@ -80,6 +80,15 @@ class ToolsTest extends Test Assert.isNull(findTemplate(disabled, "Clip.hx")); } + public function testVersionComesFromHaxelibJson():Void + { + // run.n is next to haxelib.json, here the repository is three directories up from tests/tools/bin + var root = Path.combine(Path.directory(Sys.programPath()), "../../.."); + var expected:String = Json.parse(File.getContent(Path.combine(root, "haxelib.json"))).version; + Assert.equals(expected, Tools.getVersion(root)); + Assert.equals("0.0.0", Tools.getVersion(createDirectory("no-haxelib-json"))); + } + private static function createClipSWF():SWFBuilder { return new SWFBuilder().add(SWFBuilder.createSprite(1)).link(1, "Clip"); From 78a4ff1446e807c5db1e6297f4bc37a0db79fbcb Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Tue, 27 Oct 2026 04:40:16 -0400 Subject: [PATCH 28/41] Add opt-in compact storage for Animate timelines (closes #57) With -D swf_compact_animate_timelines, Animate timeline events are kept in compact numeric buffers instead of one AnimateFrame and AnimateFrameObject graph per frame and event, and are decoded during playback. Changes from #57: the default path shares instance placement and masking with the compact path, unchanged, so both apply clipping masks and clip depths the same way. Filters are kept parsed and created by the same code as the default path, so unknown filter types only skip that filter. Frame objects without a symbol, depth or clip depth, such as the DESTROY records the exporter writes, are stored with 0 instead of null. Tests check that both paths play the same frames the same way, and CI runs the tests with the define. --- .github/workflows/main.yml | 32 + README.md | 17 + src/swf/exporters/animate/AnimateLibrary.hx | 74 ++- .../exporters/animate/AnimateSpriteSymbol.hx | 6 + src/swf/exporters/animate/AnimateTimeline.hx | 628 +++++++++++++----- .../exporters/animate/AnimateTimelineData.hx | 188 ++++++ tests/src/TestMain.hx | 1 + tests/src/tests/AnimateTimelineTest.hx | 238 +++++++ 8 files changed, 982 insertions(+), 202 deletions(-) create mode 100644 src/swf/exporters/animate/AnimateTimelineData.hx create mode 100644 tests/src/tests/AnimateTimelineTest.hx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3a7e6c33f..97517dd09 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -126,6 +126,38 @@ jobs: run: | haxelib run openfl test neko + unit-test-neko-defines: + strategy: + matrix: + define: [swf_compact_animate_timelines] + runs-on: ubuntu-latest + env: + SDL_VIDEODRIVER: "dummy" + SDL_AUDIODRIVER: "disk" + steps: + - uses: actions/checkout@v6 + + - uses: krdlab/setup-haxe@v2 + with: + haxe-version: 4.3.6 + + - name: Set HAXEPATH + run: | + echo "HAXEPATH=$HAXE_STD_PATH/.." >> $GITHUB_ENV + + - name: Install Haxe dependencies + run: | + haxelib install format --quiet + haxelib install lime --quiet + haxelib install openfl --quiet + haxelib install utest --quiet + haxelib dev swf ${{ github.workspace }} + + - name: Run tests on Neko with ${{ matrix.define }} + working-directory: tests + run: | + haxelib run openfl test neko -D${{ matrix.define }} + unit-test-tools: strategy: matrix: diff --git a/README.md b/README.md index 03e0e6a2e..8b389740a 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,23 @@ There are three primary code paths within the library: This library can be called automatically by the OpenFL/Lime command-line tools to process `` tags, or it can be used on the command-line to process SWF files into Animate ZIP files. +Compact Animate timelines +========================= + +Projects with large Animate libraries may define +`swf_compact_animate_timelines` to keep serialized timeline events in compact +numeric buffers. The default parser creates one `AnimateFrame` and +`AnimateFrameObject` graph for every frame and event, including separate +matrix, color-transform and filter objects. The compact mode decodes these +values directly during playback, reducing retained managed memory and the +amount of graph traversal performed by full garbage collections. Filters are +rare, so they are still kept parsed. + +The exported Animate asset format does not change, and standard timeline +playback remains compatible. This mode is opt-in because code that accesses +`AnimateSpriteSymbol.frames` directly will not receive the eagerly expanded +legacy metadata. + Usage ===== diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index 9e9a16f05..a6d634b4e 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -39,6 +39,7 @@ import openfl.filters.GlowFilter; @:noDebug #end @:access(swf.exporters.animate.AnimateSpriteSymbol) +@:access(swf.exporters.animate.AnimateTimelineData) @:access(swf.exporters.animate) @SuppressWarnings("checkstyle:FieldDocComment") @:keep class AnimateLibrary extends AssetLibrary @@ -749,39 +750,8 @@ import openfl.filters.GlowFilter; return symbol; } - private function __parseMatrix(values:Array):Matrix - { - return values != null ? new Matrix(values[0], values[1], values[2], values[3], __pixel(cast values[4]), __pixel(cast values[5])) : null; - } - - private function __parseShape(data:Dynamic):AnimateShapeSymbol - { - var symbol = new AnimateShapeSymbol(); - symbol.id = data.id; - symbol.__setCompactCommands(data.commands); - return symbol; - } - - private function __parseSound(data:Dynamic):AnimateSoundSymbol + private function __parseFrames(symbol:AnimateSpriteSymbol, frames:Array):Void { - var symbol = new AnimateSoundSymbol(); - symbol.id = data.id; - symbol.className = data.className; - symbol.path = data.path; - return symbol; - } - - private function __parseSprite(data:Dynamic):AnimateSpriteSymbol - { - if (data == null) return null; - - var symbol = new AnimateSpriteSymbol(); - symbol.id = data.id; - symbol.className = data.className; - symbol.baseClassName = data.baseClassName; - symbol.scale9Grid = data.scale9Grid != null ? new Rectangle(__pixel(data.scale9Grid[0]), __pixel(data.scale9Grid[1]), __pixel(data.scale9Grid[2]), - __pixel(data.scale9Grid[3])) : null; - var frames:Array = data.frames; var frame:AnimateFrame, objects:Array, object:AnimateFrameObject; @@ -829,6 +799,46 @@ import openfl.filters.GlowFilter; } symbol.frames.push(frame); } + } + + private function __parseMatrix(values:Array):Matrix + { + return values != null ? new Matrix(values[0], values[1], values[2], values[3], __pixel(cast values[4]), __pixel(cast values[5])) : null; + } + + private function __parseShape(data:Dynamic):AnimateShapeSymbol + { + var symbol = new AnimateShapeSymbol(); + symbol.id = data.id; + symbol.__setCompactCommands(data.commands); + return symbol; + } + + private function __parseSound(data:Dynamic):AnimateSoundSymbol + { + var symbol = new AnimateSoundSymbol(); + symbol.id = data.id; + symbol.className = data.className; + symbol.path = data.path; + return symbol; + } + + private function __parseSprite(data:Dynamic):AnimateSpriteSymbol + { + if (data == null) return null; + + var symbol = new AnimateSpriteSymbol(); + symbol.id = data.id; + symbol.className = data.className; + symbol.baseClassName = data.baseClassName; + symbol.scale9Grid = data.scale9Grid != null ? new Rectangle(__pixel(data.scale9Grid[0]), __pixel(data.scale9Grid[1]), __pixel(data.scale9Grid[2]), + __pixel(data.scale9Grid[3])) : null; + var frames:Array = data.frames; + #if swf_compact_animate_timelines + symbol.__setCompactTimeline(frames, this); + #else + __parseFrames(symbol, frames); + #end return symbol; } diff --git a/src/swf/exporters/animate/AnimateSpriteSymbol.hx b/src/swf/exporters/animate/AnimateSpriteSymbol.hx index 3fe6064ff..6ba4f86fd 100644 --- a/src/swf/exporters/animate/AnimateSpriteSymbol.hx +++ b/src/swf/exporters/animate/AnimateSpriteSymbol.hx @@ -18,6 +18,7 @@ class AnimateSpriteSymbol extends AnimateSymbol public var frames:Array; public var scale9Grid:Rectangle; + private var compactTimeline:AnimateTimelineData; private var library:AnimateLibrary; public function new() @@ -129,4 +130,9 @@ class AnimateSpriteSymbol extends AnimateSymbol this.library = library; __constructor(cast instance); } + + private function __setCompactTimeline(frames:Array, library:AnimateLibrary):Void + { + compactTimeline = new AnimateTimelineData(frames, library); + } } diff --git a/src/swf/exporters/animate/AnimateTimeline.hx b/src/swf/exporters/animate/AnimateTimeline.hx index 4812e61a7..4003ade5f 100644 --- a/src/swf/exporters/animate/AnimateTimeline.hx +++ b/src/swf/exporters/animate/AnimateTimeline.hx @@ -1,6 +1,7 @@ package swf.exporters.animate; import lime.utils.Log; +import swf.exporters.core.FilterType; import openfl.display.DisplayObject; import openfl.display.FrameLabel; import openfl.display.FrameScript; @@ -18,6 +19,7 @@ import openfl.filters.DisplacementMapFilter; import openfl.filters.DropShadowFilter; import openfl.filters.GlowFilter; import openfl.geom.ColorTransform; +import openfl.geom.Matrix; #if hscript import hscript.Interp; import hscript.Parser; @@ -28,10 +30,13 @@ import hscript.Parser; @:noDebug #end @:access(swf.exporters.animate.AnimateLibrary) +@:access(swf.exporters.animate.AnimateSpriteSymbol) @:access(swf.exporters.animate.AnimateSymbol) +@:access(swf.exporters.animate.AnimateTimelineData) @:access(openfl.display.DisplayObject) @:access(openfl.display.MovieClip) @:access(openfl.geom.ColorTransform) +@:access(openfl.geom.Transform) class AnimateTimeline extends Timeline { @:noCompletion private static var __instanceFieldsCacheByClassName:Map> = new Map(); @@ -61,6 +66,7 @@ class AnimateTimeline extends Timeline @:noCompletion private var __currentInstancesByFrameObjectID:Map; @:noCompletion private var __instanceFields:Array; @:noCompletion private var __lastUpdated:Map; + @:noCompletion private var __lastUpdatedCompact:Map; @:noCompletion private var __library:AnimateLibrary; @:noCompletion private var __previousFrame:Int; @:noCompletion private var __sprite:Sprite; @@ -79,29 +85,34 @@ class AnimateTimeline extends Timeline var frame:Int; var frameData:AnimateFrame; + var frameCount = __symbol.compactTimeline != null ? __symbol.compactTimeline.frameCount : __symbol.frames.length; + var frameLabels:Array; + var scriptSource:String; #if hscript var parser:Parser = null; #end - for (i in 0...__symbol.frames.length) + for (i in 0...frameCount) { frame = i + 1; - frameData = __symbol.frames[i]; + frameData = __symbol.compactTimeline == null ? __symbol.frames[i] : null; + frameLabels = frameData != null ? frameData.labels : __symbol.compactTimeline.getLabels(i); + scriptSource = frameData != null ? frameData.scriptSource : __symbol.compactTimeline.getScriptSource(i); - if (frameData.labels != null) + if (frameLabels != null) { - for (label in frameData.labels) + for (label in frameLabels) { labels.push(new FrameLabel(label, frame)); } } - if (frameData.script != null) + if (frameData != null && frameData.script != null) { scripts.push(new FrameScript(frameData.script, frame)); } - else if (frameData.scriptSource != null) + else if (scriptSource != null) { try { @@ -112,10 +123,10 @@ class AnimateTimeline extends Timeline parser.allowTypes = true; } - var script = __createScriptCallback(parser, frameData.scriptSource); + var script = __createScriptCallback(parser, scriptSource); scripts.push(new FrameScript(script, frame)); #elseif js - var script = __createScriptCallback(frameData.scriptSource); + var script = __createScriptCallback(scriptSource); scripts.push(new FrameScript(script, frame)); #end } @@ -124,17 +135,17 @@ class AnimateTimeline extends Timeline if (__symbol.className != null) { Log.warn("Unable to evaluate frame script source for symbol \"" + __symbol.className + "\" frame " + frame + "\n" - + frameData.scriptSource); + + scriptSource); } else { - Log.warn("Unable to evaluate frame script source:\n" + frameData.scriptSource); + Log.warn("Unable to evaluate frame script source:\n" + scriptSource); } } } } - scenes = [new Scene("", labels, __symbol.frames.length)]; + scenes = [new Scene("", labels, frameCount)]; } public override function attachMovieClip(movieClip:MovieClip):Void @@ -144,6 +155,12 @@ class AnimateTimeline extends Timeline public override function enterFrame(currentFrame:Int):Void { + if (__symbol != null && __symbol.compactTimeline != null) + { + __enterCompactFrame(currentFrame); + return; + } + if (__symbol != null && currentFrame != __previousFrame) { var frame:Int; @@ -224,118 +241,206 @@ class AnimateTimeline extends Timeline } currentInstances.sort(__sortDepths); + __applyCurrentInstances(currentInstances, currentMasks); + } + + __previousFrame = currentFrame; + } + } + + private function __enterCompactFrame(currentFrame:Int):Void + { + if (currentFrame == __previousFrame) return; - var existingChild:DisplayObject; - var targetDepth:Int; - var targetChild:DisplayObject; - var child:DisplayObject; - var maskApplied:Bool; + var timeline = __symbol.compactTimeline; + var data = timeline.objects; + var updateFrameStart = __previousFrame < currentFrame ? (__previousFrame == -1 ? 0 : __previousFrame) : 0; + var skipFrame = false; - for (i in 0...currentInstances.length) + if (currentFrame == 1 && __previousFrame > currentFrame && timeline.getFrameStart(0) != timeline.getFrameEnd(0)) + { + skipFrame = true; + for (i in 1...timeline.frameCount) + { + if (timeline.getFrameStart(i) != timeline.getFrameEnd(i)) { - existingChild = (i < __sprite.numChildren) ? __sprite.getChildAt(i) : null; - instance = currentInstances[i]; + skipFrame = false; + break; + } + } + } - targetDepth = instance.depth; - targetChild = instance.displayObject; + if (!skipFrame) + { + if (updateFrameStart <= 0) + { + __currentInstancesByFrameObjectID = new Map(); + } - if (existingChild != targetChild) - { - __sprite.addChildAt(targetChild, i); - } + for (i in updateFrameStart...currentFrame) + { + var position = timeline.getFrameStart(i); + var end = timeline.getFrameEnd(i); - child = targetChild; - maskApplied = false; + while (position < end) + { + var objectPosition = position; + var type:AnimateFrameObjectType = cast Std.int(data[position]); + var id = Std.int(data[position + 1]); + var instance = __activeInstancesByFrameObjectID.get(id); + position = timeline.getNextObjectPosition(position); - for (mask in currentMasks) + if (instance != null) { - if (targetDepth > mask.depth && targetDepth <= mask.clipDepth) + switch (type) { - #if (openfl >= "9.5.0") - #if flash - child.mask = mask.displayObject; - #else - child.clippingLayer = mask.displayObject; - #end - #else - child.mask = mask.displayObject; - #end - maskApplied = true; - break; + case CREATE: + __currentInstancesByFrameObjectID.set(id, instance); + __updateDisplayObjectCompact(instance.displayObject, objectPosition, true); + + case UPDATE: + __updateDisplayObjectCompact(instance.displayObject, objectPosition); + + case DESTROY: + __currentInstancesByFrameObjectID.remove(id); } } + } + } - if (currentMasks.length > 0 && !maskApplied && child.mask != null) + var currentInstances = new Array(); + var currentMasks = new Array(); + + for (instance in __currentInstancesByFrameObjectID) + { + if (currentInstances.indexOf(instance) == -1) + { + currentInstances.push(instance); + if (instance.clipDepth > 0) { - child.mask = null; + currentMasks.push(instance); } } + } + + currentInstances.sort(__sortDepths); + __applyCurrentInstances(currentInstances, currentMasks); + } + + __previousFrame = currentFrame; + } + + private function __applyCurrentInstances(currentInstances:Array, currentMasks:Array):Void + { + var instance:FrameSymbolInstance; + + var existingChild:DisplayObject; + var targetDepth:Int; + var targetChild:DisplayObject; + var child:DisplayObject; + var maskApplied:Bool; + + for (i in 0...currentInstances.length) + { + existingChild = (i < __sprite.numChildren) ? __sprite.getChildAt(i) : null; + instance = currentInstances[i]; - #if !flash - for (mask in currentMasks) + targetDepth = instance.depth; + targetChild = instance.displayObject; + + if (existingChild != targetChild) + { + __sprite.addChildAt(targetChild, i); + } + + child = targetChild; + maskApplied = false; + + for (mask in currentMasks) + { + if (targetDepth > mask.depth && targetDepth <= mask.clipDepth) { - __markTimelineMask(mask.displayObject); + #if (openfl >= "9.5.0") + #if flash + child.mask = mask.displayObject; + #else + child.clippingLayer = mask.displayObject; + #end + #else + child.mask = mask.displayObject; + #end + maskApplied = true; + break; } - #end + } + + if (currentMasks.length > 0 && !maskApplied && child.mask != null) + { + child.mask = null; + } + } - // TODO: How to tell if shapes are for a scale9Grid clip? - if (__sprite.scale9Grid != null) + #if !flash + for (mask in currentMasks) + { + __markTimelineMask(mask.displayObject); + } + #end + + // TODO: How to tell if shapes are for a scale9Grid clip? + if (__sprite.scale9Grid != null) + { + __sprite.graphics.clear(); + if (currentInstances.length > 0) + { + var displayObject = currentInstances[0].displayObject; + if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (displayObject, Shape)) { - __sprite.graphics.clear(); - if (currentInstances.length > 0) - { - var displayObject = currentInstances[0].displayObject; - if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (displayObject, Shape)) - { - var shape:Shape = cast displayObject; - // for scale9Grid to work with the shape's graphics, - // we need to move them to the __sprite instead - // because scale9Grid does not apply to children - __sprite.graphics.copyFrom(shape.graphics); - __sprite.removeChild(displayObject); - } - } + var shape:Shape = cast displayObject; + // for scale9Grid to work with the shape's graphics, + // we need to move them to the __sprite instead + // because scale9Grid does not apply to children + __sprite.graphics.copyFrom(shape.graphics); + __sprite.removeChild(displayObject); } - else - { - var child:DisplayObject; - var i = currentInstances.length; - var length = __sprite.numChildren; + } + } + else + { + var child:DisplayObject; + var i = currentInstances.length; + var length = __sprite.numChildren; - while (i < length) - { - child = __sprite.getChildAt(i); + while (i < length) + { + child = __sprite.getChildAt(i); - // TODO: Faster method of determining if this was automatically added? + // TODO: Faster method of determining if this was automatically added? - for (instance in __activeInstances) + for (instance in __activeInstances) + { + if (instance.displayObject == child) + { + // set MovieClips back to initial state (autoplay) + if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (child, MovieClip)) { - if (instance.displayObject == child) - { - // set MovieClips back to initial state (autoplay) - if (#if (haxe_ver >= 4.2) Std.isOfType #else Std.is #end (child, MovieClip)) - { - var movie:MovieClip = cast child; - movie.gotoAndPlay(1); - } - - __sprite.removeChild(child); - i--; - length--; - } + var movie:MovieClip = cast child; + movie.gotoAndPlay(1); } - i++; + __sprite.removeChild(child); + i--; + length--; } } - #if !openfljs - __updateInstanceFields(); - #end + i++; } - - __previousFrame = currentFrame; } + + #if !openfljs + __updateInstanceFields(); + #end } private function init(sprite:Sprite):Void @@ -343,90 +448,96 @@ class AnimateTimeline extends Timeline if (__activeInstances != null) return; __sprite = sprite; - __instanceFields = []; __previousFrame = -1; - __activeInstances = []; __activeInstancesByFrameObjectID = new Map(); __currentInstancesByFrameObjectID = new Map(); - __lastUpdated = new Map(); - - var frame:Int; - var frameData:AnimateFrame; - var instance:FrameSymbolInstance; - var duplicate:Bool; - var symbol:AnimateSymbol; - var displayObject:DisplayObject; - - // TODO: Create later? - for (i in 0...scenes[0].numFrames) + if (__symbol.compactTimeline != null) { - frame = i + 1; - frameData = __symbol.frames[i]; + __lastUpdatedCompact = new Map(); + __initCompactInstances(); + } + else + { + __lastUpdated = new Map(); - if (frameData.objects == null) continue; + var frame:Int; + var frameData:AnimateFrame; + var instance:FrameSymbolInstance; + var duplicate:Bool; + var symbol:AnimateSymbol; + var displayObject:DisplayObject; - for (frameObject in frameData.objects) + // TODO: Create later? + for (i in 0...scenes[0].numFrames) { - if (frameObject.type == AnimateFrameObjectType.CREATE) + frame = i + 1; + frameData = __symbol.frames[i]; + + if (frameData.objects == null) continue; + + for (frameObject in frameData.objects) { - if (__activeInstancesByFrameObjectID.exists(frameObject.id)) - { - continue; - } - else + if (frameObject.type == AnimateFrameObjectType.CREATE) { - instance = null; - duplicate = false; - - for (activeInstance in __activeInstances) + if (__activeInstancesByFrameObjectID.exists(frameObject.id)) + { + continue; + } + else { - if (activeInstance.displayObject != null - && activeInstance.characterID == frameObject.symbol - && activeInstance.depth == frameObject.depth - && activeInstance.clipDepth == frameObject.clipDepth) + instance = null; + duplicate = false; + + for (activeInstance in __activeInstances) { - // TODO: Fix duplicates in exporter - instance = activeInstance; - duplicate = true; - break; + if (activeInstance.displayObject != null + && activeInstance.characterID == frameObject.symbol + && activeInstance.depth == frameObject.depth + && activeInstance.clipDepth == frameObject.clipDepth) + { + // TODO: Fix duplicates in exporter + instance = activeInstance; + duplicate = true; + break; + } } } - } - if (instance == null) - { - symbol = __library.symbols.get(frameObject.symbol); - - if (symbol != null) + if (instance == null) { - displayObject = symbol.__createObject(__library); + symbol = __library.symbols.get(frameObject.symbol); - if (displayObject != null) + if (symbol != null) { - #if !flash - // displayObject.parent = __sprite; - // displayObject.stage = __sprite.stage; + displayObject = symbol.__createObject(__library); + + if (displayObject != null) + { + #if !flash + // displayObject.parent = __sprite; + // displayObject.stage = __sprite.stage; - // if (__sprite.stage != null) displayObject.dispatchEvent(new Event(Event.ADDED_TO_STAGE, false, false)); - #end + // if (__sprite.stage != null) displayObject.dispatchEvent(new Event(Event.ADDED_TO_STAGE, false, false)); + #end - instance = new FrameSymbolInstance(frame, frameObject.id, frameObject.symbol, frameObject.depth, displayObject, - frameObject.clipDepth); + instance = new FrameSymbolInstance(frame, frameObject.id, frameObject.symbol, frameObject.depth, displayObject, + frameObject.clipDepth); + } } } - } - - if (instance != null) - { - __activeInstancesByFrameObjectID.set(frameObject.id, instance); - if (!duplicate) + if (instance != null) { - __activeInstances.push(instance); - __updateDisplayObject(instance.displayObject, frameObject); + __activeInstancesByFrameObjectID.set(frameObject.id, instance); + + if (!duplicate) + { + __activeInstances.push(instance); + __updateDisplayObject(instance.displayObject, frameObject); + } } } } @@ -440,6 +551,76 @@ class AnimateTimeline extends Timeline enterFrame(1); } + private function __initCompactInstances():Void + { + var timeline = __symbol.compactTimeline; + var data = timeline.objects; + + for (i in 0...timeline.frameCount) + { + var frame = i + 1; + var position = timeline.getFrameStart(i); + var end = timeline.getFrameEnd(i); + + while (position < end) + { + var objectPosition = position; + var type:AnimateFrameObjectType = cast Std.int(data[position]); + var id = Std.int(data[position + 1]); + var symbolID = Std.int(data[position + 2]); + var depth = Std.int(data[position + 3]); + var clipDepth = Std.int(data[position + 4]); + position = timeline.getNextObjectPosition(position); + + if (type != AnimateFrameObjectType.CREATE || __activeInstancesByFrameObjectID.exists(id)) + { + continue; + } + + var instance:FrameSymbolInstance = null; + var duplicate = false; + + for (activeInstance in __activeInstances) + { + if (activeInstance.displayObject != null + && activeInstance.characterID == symbolID + && activeInstance.depth == depth + && activeInstance.clipDepth == clipDepth) + { + // TODO: Fix duplicates in exporter + instance = activeInstance; + duplicate = true; + break; + } + } + + if (instance == null) + { + var symbol = __library.symbols.get(symbolID); + if (symbol != null) + { + var displayObject = symbol.__createObject(__library); + if (displayObject != null) + { + instance = new FrameSymbolInstance(frame, id, symbolID, depth, displayObject, clipDepth); + } + } + } + + if (instance != null) + { + __activeInstancesByFrameObjectID.set(id, instance); + + if (!duplicate) + { + __activeInstances.push(instance); + __updateDisplayObjectCompact(instance.displayObject, objectPosition); + } + } + } + } + } + public override function initializeSprite(sprite:Sprite):Void { if (__activeInstances != null) return; @@ -451,6 +632,7 @@ class AnimateTimeline extends Timeline __currentInstancesByFrameObjectID = null; __instanceFields = null; __lastUpdated = null; + __lastUpdatedCompact = null; __sprite = null; __previousFrame = -1; } @@ -500,27 +682,7 @@ class AnimateTimeline extends Timeline if (frameObject.filters != null) { - var filters:Array = []; - - for (filter in frameObject.filters) - { - switch (filter) - { - case BlurFilter(blurX, blurY, quality): - filters.push(new BlurFilter(blurX, blurY, quality)); - - case ColorMatrixFilter(matrix): - filters.push(new ColorMatrixFilter(matrix)); - - case DropShadowFilter(distance, angle, color, alpha, blurX, blurY, strength, quality, inner, knockout, hideObject): - filters.push(new DropShadowFilter(distance, angle, color, alpha, blurX, blurY, strength, quality, inner, knockout, hideObject)); - - case GlowFilter(color, alpha, blurX, blurY, strength, quality, inner, knockout): - filters.push(new GlowFilter(color, alpha, blurX, blurY, strength, quality, inner, knockout)); - } - } - - displayObject.filters = filters; + displayObject.filters = __createFilters(frameObject.filters); } else { @@ -556,6 +718,107 @@ class AnimateTimeline extends Timeline __lastUpdated.set(displayObject, frameObject); } + @:noCompletion private function __updateDisplayObjectCompact(displayObject:DisplayObject, objectPosition:Int, reset:Bool = false):Void + { + if (displayObject == null) return; + if (__lastUpdatedCompact.get(displayObject) == objectPosition) return; + + var timeline = __symbol.compactTimeline; + var data = timeline.objects; + var references = timeline.references; + var flags = Std.int(data[objectPosition + 5]); + var position = objectPosition + 6; + + if ((flags & AnimateTimelineData.HAS_MATRIX) != 0) + { + #if flash + displayObject.transform.matrix = new Matrix(data[position], data[position + 1], data[position + 2], data[position + 3], data[position + 4] / 20, + data[position + 5] / 20); + #else + displayObject.transform.__setTransform(data[position], data[position + 1], data[position + 2], data[position + 3], data[position + 4] / 20, + data[position + 5] / 20); + #end + position += 6; + } + + if ((flags & AnimateTimelineData.HAS_COLOR_TRANSFORM) != 0) + { + #if flash + displayObject.transform.colorTransform = new ColorTransform(data[position] / 20, data[position + 1] / 20, data[position + 2] / 20, + data[position + 3] / 20, data[position + 4] / 20, data[position + 5] / 20, data[position + 6] / 20, data[position + 7] / 20); + #else + var colorTransform = ColorTransform.__pool.get(); + colorTransform.redMultiplier = data[position] / 20; + colorTransform.greenMultiplier = data[position + 1] / 20; + colorTransform.blueMultiplier = data[position + 2] / 20; + colorTransform.alphaMultiplier = data[position + 3] / 20; + colorTransform.redOffset = data[position + 4] / 20; + colorTransform.greenOffset = data[position + 5] / 20; + colorTransform.blueOffset = data[position + 6] / 20; + colorTransform.alphaOffset = data[position + 7] / 20; + displayObject.transform.colorTransform = colorTransform; + ColorTransform.__pool.release(colorTransform); + #end + position += 8; + } + else if (reset #if !flash && !displayObject.transform.colorTransform.__isDefault(false) #end) + { + #if flash + displayObject.transform.colorTransform = new ColorTransform(); + #else + var colorTransform = ColorTransform.__pool.get(); + displayObject.transform.colorTransform = colorTransform; + ColorTransform.__pool.release(colorTransform); + #end + } + + #if flash + displayObject.transform = displayObject.transform; + #end + + if ((flags & AnimateTimelineData.HAS_FILTERS) != 0) + { + displayObject.filters = __createFilters(cast references[Std.int(data[position++])]); + } + else + { + displayObject.filters = null; + } + + if ((flags & AnimateTimelineData.HAS_NAME) != 0) + { + displayObject.name = cast references[Std.int(data[position++])]; + } + + if ((flags & AnimateTimelineData.HAS_BLEND_MODE) != 0) + { + displayObject.blendMode = cast references[Std.int(data[position++])]; + } + + if ((flags & AnimateTimelineData.HAS_CACHE_AS_BITMAP) != 0) + { + displayObject.cacheAsBitmap = (flags & AnimateTimelineData.CACHE_AS_BITMAP) != 0; + } + + if ((flags & AnimateTimelineData.HAS_VISIBLE) != 0) + { + displayObject.visible = (flags & AnimateTimelineData.VISIBLE) != 0; + } + + #if (openfl >= "9.5.0") + if ((flags & AnimateTimelineData.HAS_META_DATA) != 0) + { + displayObject.metaData = references[Std.int(data[position++])]; + } + #end + + #if openfljs + Reflect.setField(__sprite, displayObject.name, displayObject); + #end + + __lastUpdatedCompact.set(displayObject, objectPosition); + } + @:noCompletion private function __updateInstanceFields():Void { for (field in __instanceFields) @@ -597,6 +860,31 @@ class AnimateTimeline extends Timeline return cached; } + @:noCompletion private static function __createFilters(filters:Array):Array + { + var result:Array = []; + + for (filter in filters) + { + switch (filter) + { + case BlurFilter(blurX, blurY, quality): + result.push(new BlurFilter(blurX, blurY, quality)); + + case ColorMatrixFilter(matrix): + result.push(new ColorMatrixFilter(matrix)); + + case DropShadowFilter(distance, angle, color, alpha, blurX, blurY, strength, quality, inner, knockout, hideObject): + result.push(new DropShadowFilter(distance, angle, color, alpha, blurX, blurY, strength, quality, inner, knockout, hideObject)); + + case GlowFilter(color, alpha, blurX, blurY, strength, quality, inner, knockout): + result.push(new GlowFilter(color, alpha, blurX, blurY, strength, quality, inner, knockout)); + } + } + + return result; + } + #if hscript @:noCompletion private function __createScriptCallback(parser:Parser, scriptSource:String):MovieClip->Void { diff --git a/src/swf/exporters/animate/AnimateTimelineData.hx b/src/swf/exporters/animate/AnimateTimelineData.hx new file mode 100644 index 000000000..e7a42fc07 --- /dev/null +++ b/src/swf/exporters/animate/AnimateTimelineData.hx @@ -0,0 +1,188 @@ +package swf.exporters.animate; + +import haxe.ds.Vector; + +#if !openfl_debug +@:fileXml('tags="haxe,release"') +@:noDebug +#end +@:access(swf.exporters.animate.AnimateLibrary) +@:noCompletion +class AnimateTimelineData +{ + public static inline var HAS_MATRIX = 1; + public static inline var HAS_COLOR_TRANSFORM = 2; + public static inline var HAS_FILTERS = 4; + public static inline var HAS_NAME = 8; + public static inline var HAS_BLEND_MODE = 16; + public static inline var HAS_CACHE_AS_BITMAP = 32; + public static inline var CACHE_AS_BITMAP = 64; + public static inline var HAS_VISIBLE = 128; + public static inline var VISIBLE = 256; + public static inline var HAS_META_DATA = 512; + + public var frameCount(default, null):Int; + public var frameOffsets(default, null):Array; + public var objects(default, null):Vector; + public var references(default, null):Array; + + private var encodedObjects:Array; + private var labelsByFrame:Map>; + private var scriptSourcesByFrame:Map; + + public function new(frames:Array, library:AnimateLibrary) + { + frameCount = frames != null ? frames.length : 0; + frameOffsets = []; + encodedObjects = []; + + if (frames == null) + { + frameOffsets.push(0); + objects = new Vector(0); + encodedObjects = null; + return; + } + + for (frameIndex in 0...frames.length) + { + var frameData:Dynamic = frames[frameIndex]; + frameOffsets.push(encodedObjects.length); + + if (Reflect.hasField(frameData, "label")) + { + if (labelsByFrame == null) labelsByFrame = new Map(); + labelsByFrame.set(frameIndex, [Reflect.field(frameData, "label")]); + } + else if (Reflect.hasField(frameData, "labels")) + { + if (labelsByFrame == null) labelsByFrame = new Map(); + labelsByFrame.set(frameIndex, Reflect.field(frameData, "labels")); + } + + if (Reflect.hasField(frameData, "scriptSource")) + { + if (scriptSourcesByFrame == null) scriptSourcesByFrame = new Map(); + scriptSourcesByFrame.set(frameIndex, Reflect.field(frameData, "scriptSource")); + } + + var frameObjects:Array = Reflect.field(frameData, "objects"); + if (frameObjects != null) + { + for (frameObject in frameObjects) + { + __encodeFrameObject(frameObject, library); + } + } + } + + frameOffsets.push(encodedObjects.length); + objects = new Vector(encodedObjects.length); + for (i in 0...encodedObjects.length) + { + objects[i] = encodedObjects[i]; + } + encodedObjects = null; + } + + public inline function getLabels(frameIndex:Int):Array + { + return labelsByFrame != null ? labelsByFrame.get(frameIndex) : null; + } + + public inline function getScriptSource(frameIndex:Int):String + { + return scriptSourcesByFrame != null ? scriptSourcesByFrame.get(frameIndex) : null; + } + + public inline function getFrameStart(frameIndex:Int):Int + { + return frameOffsets[frameIndex]; + } + + public inline function getFrameEnd(frameIndex:Int):Int + { + return frameOffsets[frameIndex + 1]; + } + + public inline function getNextObjectPosition(position:Int):Int + { + var flags = Std.int(objects[position + 5]); + position += 6; + + if ((flags & HAS_MATRIX) != 0) position += 6; + if ((flags & HAS_COLOR_TRANSFORM) != 0) position += 8; + if ((flags & HAS_FILTERS) != 0) position++; + if ((flags & HAS_NAME) != 0) position++; + if ((flags & HAS_BLEND_MODE) != 0) position++; + if ((flags & HAS_META_DATA) != 0) position++; + + return position; + } + + private function __addReference(value:Dynamic):Int + { + if (references == null) references = []; + references.push(value); + return references.length - 1; + } + + private static inline function __number(value:Dynamic):Float + { + return value != null ? value : 0; + } + + private function __encodeFrameObject(frameObject:Dynamic, library:AnimateLibrary):Void + { + var matrix:Array = Reflect.field(frameObject, "matrix"); + var colorTransform:Array = Reflect.field(frameObject, "colorTransform"); + var filters:Array> = Reflect.field(frameObject, "filters"); + var name:Dynamic = Reflect.field(frameObject, "name"); + var blendMode:Dynamic = Reflect.field(frameObject, "blendMode"); + var cacheAsBitmap:Dynamic = Reflect.field(frameObject, "cacheAsBitmap"); + var visible:Dynamic = Reflect.field(frameObject, "visible"); + var metaData:Dynamic = Reflect.field(frameObject, "metaData"); + var flags = 0; + + if (matrix != null) flags |= HAS_MATRIX; + if (colorTransform != null) flags |= HAS_COLOR_TRANSFORM; + if (filters != null) flags |= HAS_FILTERS; + if (name != null) flags |= HAS_NAME; + if (blendMode != null) flags |= HAS_BLEND_MODE; + if (cacheAsBitmap != null) + { + flags |= HAS_CACHE_AS_BITMAP; + if (cacheAsBitmap) flags |= CACHE_AS_BITMAP; + } + if (visible != null) + { + flags |= HAS_VISIBLE; + if (visible) flags |= VISIBLE; + } + if (metaData != null) flags |= HAS_META_DATA; + + // DESTROY records only have a type and an ID + encodedObjects.push(__number(Reflect.field(frameObject, "type"))); + encodedObjects.push(__number(Reflect.field(frameObject, "id"))); + encodedObjects.push(__number(Reflect.field(frameObject, "symbol"))); + encodedObjects.push(__number(Reflect.field(frameObject, "depth"))); + encodedObjects.push(__number(Reflect.field(frameObject, "clipDepth"))); + encodedObjects.push(flags); + + if (matrix != null) + { + for (value in matrix) encodedObjects.push(value); + } + + if (colorTransform != null) + { + for (value in colorTransform) encodedObjects.push(value); + } + + // filters are rare, so they are kept parsed, the same as without compact timelines + if (filters != null) encodedObjects.push(__addReference(library.__parseFilters(filters))); + if (name != null) encodedObjects.push(__addReference(name)); + if (blendMode != null) encodedObjects.push(__addReference(Std.string(blendMode))); + if (metaData != null) encodedObjects.push(__addReference(metaData)); + } +} diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index d9ef2eefb..b6dfb43b9 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -12,6 +12,7 @@ class TestMain extends Sprite runner.addCase(new tests.AnimateGeneratedClassTest()); runner.addCase(new tests.AnimateLibraryTest()); runner.addCase(new tests.AnimateShapeSymbolTest()); + runner.addCase(new tests.AnimateTimelineTest()); runner.addCase(new tests.ShapeExportTest()); runner.addCase(new tests.ShapesTest()); #if !flash diff --git a/tests/src/tests/AnimateTimelineTest.hx b/tests/src/tests/AnimateTimelineTest.hx new file mode 100644 index 000000000..aa0eb945f --- /dev/null +++ b/tests/src/tests/AnimateTimelineTest.hx @@ -0,0 +1,238 @@ +package tests; + +import openfl.display.MovieClip; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.animate.AnimateShapeSymbol; +import swf.exporters.animate.AnimateSpriteSymbol; +import swf.exporters.animate.AnimateTimeline; +import utest.Assert; +import utest.Test; + +@:access(openfl.display.DisplayObject) +@:access(swf.exporters.animate.AnimateLibrary) +@:access(swf.exporters.animate.AnimateSpriteSymbol) +class AnimateTimelineTest extends Test +{ + public function testCompactTimelinePlayback():Void + { + var library = new AnimateLibrary("timeline-test", "timeline-test"); + library.frameRate = 30; + library.symbols = new Map(); + + var childSymbol = new AnimateShapeSymbol(); + childSymbol.id = 2; + childSymbol.commands = []; + library.symbols.set(childSymbol.id, childSymbol); + + var symbol = new AnimateSpriteSymbol(); + symbol.id = 1; + var frames:Array = [ + { + label: "start", + objects: [ + { + type: 0, + id: 10, + symbol: 2, + depth: 1, + clipDepth: 0, + matrix: [1, 0, 0, 1, 200, 400], + colorTransform: [20, 20, 20, 10, 0, 0, 0, 0], + filters: [[0, 4, 6, 2]], + name: "compactChild", + visible: false + } + ] + }, + { + objects: [ + { + type: 1, + id: 10, + symbol: 2, + depth: 1, + clipDepth: 0, + matrix: [2, 0, 0, 3, 600, 800], + visible: true + } + ] + }, + { + objects: [ + { + type: 2, + id: 10, + symbol: 2, + depth: 1, + clipDepth: 0 + } + ] + } + ]; + symbol.__setCompactTimeline(frames, library); + + var timeline = new AnimateTimeline(library, symbol); + var movieClip = new MovieClip(); + timeline.attachMovieClip(movieClip); + + Assert.equals(3, timeline.scenes[0].numFrames); + Assert.equals("start", timeline.scenes[0].labels[0].name); + Assert.equals(1, movieClip.numChildren); + + var child = movieClip.getChildAt(0); + Assert.equals("compactChild", child.name); + Assert.equals(10.0, child.x); + Assert.equals(20.0, child.y); + Assert.equals(0.5, child.alpha); + Assert.isFalse(child.visible); + Assert.equals(1, child.filters.length); + + timeline.enterFrame(2); + Assert.equals(child, movieClip.getChildAt(0)); + Assert.equals(30.0, child.x); + Assert.equals(40.0, child.y); + Assert.equals(2.0, child.scaleX); + Assert.equals(3.0, child.scaleY); + Assert.equals(0.5, child.alpha); + Assert.isTrue(child.visible); + Assert.equals(0, child.filters.length); + + timeline.enterFrame(3); + Assert.equals(0, movieClip.numChildren); + } + + public function testParseSpriteStorage():Void + { + var library = createLibrary(); + var symbol = library.__parseSprite({id: 1, frames: [{objects: [createObject(0, 10, 1, 1, 0)]}]}); + #if swf_compact_animate_timelines + Assert.notNull(symbol.compactTimeline); + Assert.equals(0, symbol.frames.length); + #else + Assert.isNull(symbol.compactTimeline); + Assert.equals(1, symbol.frames.length); + #end + } + + #if !flash + public function testCompactTimelineMatchesDefault():Void + { + var frames:Array = [ + {label: "start", objects: [createObject(0, 10, 1, 1, 0, "first", [1, 0, 0, 1, 200, 400]), createObject(0, 11, 1, 2, 3)]}, + {objects: [createObject(1, 10, 1, 1, 0, null, [2, 0, 0, 3, 600, 800], [20, 20, 20, 10, 0, 0, 0, 0])]}, + {objects: [{type: 2, id: 11}, createObject(0, 12, 1, 3, 0, "second")]}, + {objects: [{type: 2, id: 10}, {type: 2, id: 12}]} + ]; + + var defaultClip = createClip(frames, false); + var compactClip = createClip(frames, true); + + for (frame in 1...frames.length + 1) + { + defaultClip.gotoAndStop(frame); + compactClip.gotoAndStop(frame); + Assert.equals(describe(defaultClip), describe(compactClip), "frame " + frame); + } + } + + public function testCompactClippingCharacterWithoutTargetIsMask():Void + { + var clip = createClip([{objects: [createObject(0, 10, 1, 1, 5)]}], true); + Assert.isTrue(clip.getChildAt(0).__isMask); + } + + public function testCompactClipDepthChangeCreatesNewInstance():Void + { + var clip = createClip([ + {objects: [createObject(0, 10, 1, 1, 0)]}, + {objects: [{type: 2, id: 10}, createObject(0, 11, 1, 1, 3)]} + ], true); + + var first = clip.getChildAt(0); + clip.gotoAndStop(2); + var second = clip.getChildAt(0); + Assert.notEquals(first, second); + Assert.isTrue(second.__isMask); + } + + public function testUnknownFiltersAreSkipped():Void + { + // an unknown filter type only drops that filter + var object = createObject(0, 10, 1, 1, 0); + var filters:Array> = [[0, 4, 6, 2], [99, 1, 2, 3], [3, 0xFF0000, 1, 6, 6, 2, 1, false, false]]; + object.filters = filters; + + for (compact in [false, true]) + { + var filters = createClip([{objects: [object]}], compact).getChildAt(0).filters; + Assert.equals(2, filters.length); + Assert.isOfType(filters[0], openfl.filters.BlurFilter); + Assert.isOfType(filters[1], openfl.filters.GlowFilter); + } + } + + private static function createClip(frames:Array, compact:Bool):MovieClip + { + var library = createLibrary(); + var symbol:AnimateSpriteSymbol; + if (compact) + { + symbol = new AnimateSpriteSymbol(); + symbol.id = 1; + symbol.__setCompactTimeline(frames, library); + } + else + { + symbol = new AnimateSpriteSymbol(); + symbol.id = 1; + library.__parseFrames(symbol, frames); + } + library.symbols.set(100, symbol); + return cast symbol.__createObject(library); + } + + private static function describe(clip:MovieClip):String + { + var children = []; + for (i in 0...clip.numChildren) + { + var child = clip.getChildAt(i); + // unnamed objects get an automatic instance name from OpenFL, which depends on creation order + var name = StringTools.startsWith(child.name, "instance") ? "" : child.name; + var values:Array = [name, child.x, child.y, child.scaleX, child.scaleY, child.alpha, child.visible, child.__isMask == true]; + children.push(values.join(",")); + } + return children.join(" | "); + } + #end + + private static function createLibrary():AnimateLibrary + { + var library = new AnimateLibrary("compact-timeline-test", "compact-timeline-test"); + library.frameRate = 24; + library.symbols = new Map(); + library.symbolsByClassName = new Map(); + library.bitmapSymbols = []; + + var shape = new AnimateShapeSymbol(); + shape.id = 1; + shape.commands = []; + library.symbols.set(shape.id, shape); + return library; + } + + private static function createObject(type:Int, id:Int, symbol:Int, depth:Int, clipDepth:Int, name:String = null, matrix:Array = null, + colorTransform:Array = null):Dynamic + { + return { + type: type, + id: id, + symbol: symbol, + depth: depth, + clipDepth: clipDepth, + name: name, + matrix: matrix, + colorTransform: colorTransform + }; + } +} From 7705213a085983ebe6305a2dad1fd092e821c4c3 Mon Sep 17 00:00:00 2001 From: "Enzo Persillet (Tutez)" Date: Tue, 27 Oct 2026 04:48:36 -0400 Subject: [PATCH 29/41] Add opt-in hardware cache for Animate bitmaps (closes #56) With -D swf_hardware_bitmap_cache, bitmaps used directly share one BitmapData each, and release their decoded CPU image after the texture is uploaded. Changes from #56: shapes are not probed with OpenFL's hardware graphics check. OpenFL only draws rectangles, quads and triangles in hardware, so Animate shapes, which are drawn with lineTo and curveTo, always use software rendering and need the pixels of their bitmap fills. Bitmaps used by shapes are now marked readable when the library loads, from both compact and expanded shape commands, which also covers scale9 grids. CI runs the tests with the define, and with both opt-in defines. --- .github/workflows/main.yml | 2 +- README.md | 14 ++ .../exporters/animate/AnimateBitmapSymbol.hx | 18 ++- src/swf/exporters/animate/AnimateLibrary.hx | 113 ++++++++++++++ .../exporters/animate/AnimateShapeSymbol.hx | 41 ++++- tests/src/TestMain.hx | 3 + tests/src/tests/AnimateBitmapSymbolTest.hx | 145 ++++++++++++++++++ 7 files changed, 332 insertions(+), 4 deletions(-) create mode 100644 tests/src/tests/AnimateBitmapSymbolTest.hx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 97517dd09..64ffebdde 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -129,7 +129,7 @@ jobs: unit-test-neko-defines: strategy: matrix: - define: [swf_compact_animate_timelines] + define: [swf_compact_animate_timelines, swf_hardware_bitmap_cache, "swf_compact_animate_timelines -Dswf_hardware_bitmap_cache"] runs-on: ubuntu-latest env: SDL_VIDEODRIVER: "dummy" diff --git a/README.md b/README.md index 8b389740a..dd9667498 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,20 @@ values directly during playback, reducing retained managed memory and the amount of graph traversal performed by full garbage collections. Filters are rare, so they are still kept parsed. +Hardware-only Animate bitmap cache +================================== + +Native hardware-rendered projects may define `swf_hardware_bitmap_cache` to +share each Animate bitmap asset between bitmap instances. After its texture is +uploaded, the decoded CPU image is released. OpenFL draws shape paths in +software, which reads the pixels of bitmap fills, so bitmaps used by shapes +keep one shared readable `BitmapData` instead. + +This mode is opt-in because cached bitmap assets used by direct `Bitmap` +instances become non-readable. Pixel read/write APIs and software-renderer +fallback are not supported for those bitmaps, and their textures cannot be +restored after a graphics context is lost. + The exported Animate asset format does not change, and standard timeline playback remains compatible. This mode is opt-in because code that accesses `AnimateSpriteSymbol.frames` directly will not receive the eagerly expanded diff --git a/src/swf/exporters/animate/AnimateBitmapSymbol.hx b/src/swf/exporters/animate/AnimateBitmapSymbol.hx index 3b5e06ea6..424eec78d 100644 --- a/src/swf/exporters/animate/AnimateBitmapSymbol.hx +++ b/src/swf/exporters/animate/AnimateBitmapSymbol.hx @@ -22,9 +22,25 @@ class AnimateBitmapSymbol extends AnimateSymbol private override function __createObject(library:AnimateLibrary):Bitmap { #if lime - return new Bitmap(BitmapData.fromImage(library.getImage(path)), PixelSnapping.AUTO, smooth != false); + return new Bitmap(__createBitmapData(library), PixelSnapping.AUTO, smooth != false); #else return null; #end } + + private function __createBitmapData(library:AnimateLibrary):BitmapData + { + #if (lime && !flash && swf_hardware_bitmap_cache) + return library.__getHardwareBitmapData(this); + #else + return __createBitmapDataUncached(library); + #end + } + + @:allow(swf.exporters.animate.AnimateLibrary) + private function __createBitmapDataUncached(library:AnimateLibrary):BitmapData + { + var image = library.getImage(path); + return image != null ? BitmapData.fromImage(image) : null; + } } diff --git a/src/swf/exporters/animate/AnimateLibrary.hx b/src/swf/exporters/animate/AnimateLibrary.hx index a6d634b4e..9543d9fc1 100644 --- a/src/swf/exporters/animate/AnimateLibrary.hx +++ b/src/swf/exporters/animate/AnimateLibrary.hx @@ -5,6 +5,7 @@ import lime.graphics.Image; import lime.graphics.ImageChannel; import lime.math.Vector2; import swf.exporters.core.FilterType; +import openfl.display.BitmapData; import openfl.display.MovieClip; import openfl.events.Event; import openfl.events.IOErrorEvent; @@ -63,6 +64,11 @@ import openfl.filters.GlowFilter; private var bitmapClassNames:Map; private var bitmapSymbols:Array; private var frameRate:Float; + #if (lime && !flash && swf_hardware_bitmap_cache) + private var hardwareBitmapData:Map; + private var readableBitmapDataKeys:Map; + private var readableShapeBitmapData:Map; + #end private var id:String; private var instanceID:String; private var preloading:Bool; @@ -83,6 +89,11 @@ import openfl.filters.GlowFilter; alphaCheck = new Map(); bitmapClassNames = new Map(); soundClassNames = new Map(); + #if (lime && !flash && swf_hardware_bitmap_cache) + hardwareBitmapData = new Map(); + readableBitmapDataKeys = new Map(); + readableShapeBitmapData = new Map(); + #end #if (ios || tvos) rootPath = "assets/"; @@ -151,6 +162,99 @@ import openfl.filters.GlowFilter; return instances.get(uuid); } + #if (lime && !flash && swf_hardware_bitmap_cache) + @:allow(swf.exporters.animate.AnimateBitmapSymbol) + private function __getHardwareBitmapData(symbol:AnimateBitmapSymbol):BitmapData + { + var key = __getBitmapDataKey(symbol); + if (readableBitmapDataKeys.exists(key)) + { + return __getReadableShapeBitmapData(symbol); + } + + var bitmapData = hardwareBitmapData.get(key); + + if (bitmapData == null) + { + bitmapData = symbol.__createBitmapDataUncached(this); + + if (bitmapData != null) + { + bitmapData.disposeImage(); + hardwareBitmapData.set(key, bitmapData); + + if (symbol.path != null) + { + cachedImages.remove(symbol.path); + alphaCheck.remove(symbol.path); + } + + if (symbol.alpha != null) + { + cachedImages.remove(symbol.alpha); + } + } + } + + return bitmapData; + } + + @:allow(swf.exporters.animate.AnimateShapeSymbol) + private function __markBitmapDataReadable(symbol:AnimateBitmapSymbol):Void + { + if (symbol != null) + { + readableBitmapDataKeys.set(__getBitmapDataKey(symbol), true); + } + } + + @:allow(swf.exporters.animate.AnimateShapeSymbol) + private function __getReadableShapeBitmapData(symbol:AnimateBitmapSymbol):BitmapData + { + var key = __getBitmapDataKey(symbol); + readableBitmapDataKeys.set(key, true); + var bitmapData = readableShapeBitmapData.get(key); + + if (bitmapData == null) + { + bitmapData = symbol.__createBitmapDataUncached(this); + + if (bitmapData != null) + { + readableShapeBitmapData.set(key, bitmapData); + } + } + + return bitmapData; + } + + private function __getBitmapDataKey(symbol:AnimateBitmapSymbol):String + { + return symbol.path != null + ? "path:" + symbol.path + "|alpha:" + (symbol.alpha != null ? symbol.alpha : "") + : "symbol:" + symbol.id; + } + + // Shapes are drawn in software, so bitmaps used by shape fills keep their pixels + private function __markShapeBitmapsReadable():Void + { + for (symbol in symbols) + { + #if (haxe_ver >= 4.2) + if (Std.isOfType(symbol, AnimateShapeSymbol)) + #else + if (Std.is(symbol, AnimateShapeSymbol)) + #end + { + for (bitmapID in cast(symbol, AnimateShapeSymbol).__getBitmapFillIDs()) + { + __markBitmapDataReadable(cast symbols.get(bitmapID)); + } + } + } + } + #end + #if lime public override function getAudioBuffer(id:String):AudioBuffer { @@ -329,6 +433,10 @@ import openfl.filters.GlowFilter; } } + #if (lime && !flash && swf_hardware_bitmap_cache) + __markShapeBitmapsReadable(); + #end + // SWFLite.instances.set(instanceID, swf); __load().onProgress(promise.progress).onError(promise.error).onComplete(function(_) @@ -478,6 +586,11 @@ import openfl.filters.GlowFilter; public override function unload():Void { instances.remove(instanceID); + #if (lime && !flash && swf_hardware_bitmap_cache) + hardwareBitmapData = new Map(); + readableBitmapDataKeys = new Map(); + readableShapeBitmapData = new Map(); + #end if (symbols == null) return; // if (swf == null) return; diff --git a/src/swf/exporters/animate/AnimateShapeSymbol.hx b/src/swf/exporters/animate/AnimateShapeSymbol.hx index 8a897ab73..0165936fa 100644 --- a/src/swf/exporters/animate/AnimateShapeSymbol.hx +++ b/src/swf/exporters/animate/AnimateShapeSymbol.hx @@ -26,6 +26,9 @@ class AnimateShapeSymbol extends AnimateSymbol public var commands:Array; public var rendered:Shape; + #if (lime && !flash && swf_hardware_bitmap_cache) + private var compactBitmapFillIDs:Array; + #end private var compactCommands:Array; public function new() @@ -113,15 +116,45 @@ class AnimateShapeSymbol extends AnimateSymbol { #if lime var bitmapSymbol:AnimateBitmapSymbol = cast library.symbols.get(bitmapID); + #if (!flash && swf_hardware_bitmap_cache) + // OpenFL draws shape paths in software, which reads the pixels of bitmap fills + var bitmapData = library.__getReadableShapeBitmapData(bitmapSymbol); + #else var bitmap = library.getImage(bitmapSymbol.path); + var bitmapData = bitmap != null ? BitmapData.fromImage(bitmap) : null; + #end - if (bitmap != null) + if (bitmapData != null) { - graphics.beginBitmapFill(BitmapData.fromImage(bitmap), matrix, repeat, smooth); + graphics.beginBitmapFill(bitmapData, matrix, repeat, smooth); } #end } + #if (lime && !flash && swf_hardware_bitmap_cache) + @:allow(swf.exporters.animate.AnimateLibrary) + private function __getBitmapFillIDs():Array + { + if (compactCommands != null) return compactBitmapFillIDs != null ? compactBitmapFillIDs : []; + + var ids = []; + if (commands != null) + { + for (command in commands) + { + switch (command) + { + case BeginBitmapFill(bitmapID, _, _, _): + ids.push(bitmapID); + + default: + } + } + } + return ids; + } + #end + @:allow(swf.exporters.animate.AnimateLibrary) private function __setCompactCommands(data:Array):Void { @@ -136,6 +169,10 @@ class AnimateShapeSymbol extends AnimateSymbol switch (type) { case BEGIN_BITMAP_FILL: + #if (lime && !flash && swf_hardware_bitmap_cache) + if (compactBitmapFillIDs == null) compactBitmapFillIDs = []; + compactBitmapFillIDs.push(data[i + 1]); + #end compactCommands.push(data[i + 1]); __encodeMatrix(compactCommands, data[i + 2]); compactCommands.push(data[i + 3] ? 1 : 0); diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index b6dfb43b9..b60ce704b 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -9,6 +9,9 @@ class TestMain extends Sprite super(); var runner = new Runner(); + #if !flash + runner.addCase(new tests.AnimateBitmapSymbolTest()); + #end runner.addCase(new tests.AnimateGeneratedClassTest()); runner.addCase(new tests.AnimateLibraryTest()); runner.addCase(new tests.AnimateShapeSymbolTest()); diff --git a/tests/src/tests/AnimateBitmapSymbolTest.hx b/tests/src/tests/AnimateBitmapSymbolTest.hx new file mode 100644 index 000000000..5ccd26b8d --- /dev/null +++ b/tests/src/tests/AnimateBitmapSymbolTest.hx @@ -0,0 +1,145 @@ +package tests; + +import lime.graphics.Image; +import openfl.display.BitmapData; +import swf.exporters.animate.AnimateBitmapSymbol; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.animate.AnimateShapeCommand; +import swf.exporters.animate.AnimateShapeSymbol; +import utest.Assert; +import utest.Test; + +@:access(lime.utils.AssetLibrary) +@:access(openfl.display.BitmapData) +@:access(openfl.display.Graphics) +@:access(openfl.display._internal.DrawCommandBuffer) +@:access(swf.exporters.animate.AnimateBitmapSymbol) +@:access(swf.exporters.animate.AnimateLibrary) +@:access(swf.exporters.animate.AnimateShapeSymbol) +class AnimateBitmapSymbolTest extends Test +{ + // these tests read OpenFL's draw commands and BitmapData.readable, which Flash does not have + #if !flash + public function testHardwareBitmapCacheIsOptIn():Void + { + var library = createLibrary("bitmap-test"); + var symbol = addBitmap(library, 1, "bitmap.png"); + var image = library.cachedImages.get(symbol.path); + + var first = symbol.__createBitmapData(library); + var second = symbol.__createBitmapData(library); + Assert.notNull(first); + Assert.notNull(second); + + #if (lime && swf_hardware_bitmap_cache) + // a bitmap only used directly shares one BitmapData, without its CPU image + Assert.isTrue(first == second); + Assert.isFalse(first.readable); + Assert.isFalse(library.cachedImages.exists(symbol.path)); + Assert.isTrue(first.image == image); + #else + Assert.isFalse(first == second); + Assert.isTrue(first.readable); + Assert.isTrue(second.readable); + Assert.isTrue(library.cachedImages.exists(symbol.path)); + #end + } + + public function testShapeBitmapFillsStayReadable():Void + { + var library = createLibrary("shape-bitmap-test"); + var bitmapSymbol = addBitmap(library, 1, "shape-bitmap.png"); + var image = library.cachedImages.get(bitmapSymbol.path); + var shapeSymbol = addShape(library, 2, bitmapSymbol.id); + + var bitmapData:BitmapData = cast shapeSymbol.__createObject(library).graphics.__commands.o[0]; + Assert.notNull(bitmapData); + Assert.isTrue(bitmapData.readable); + + #if (lime && swf_hardware_bitmap_cache) + // OpenFL draws shape paths in software, so the fill keeps its pixels and is shared + Assert.isTrue(bitmapData.image == image); + var directBitmapData = bitmapSymbol.__createBitmapData(library); + Assert.isTrue(directBitmapData.readable); + Assert.isTrue(bitmapData == directBitmapData); + Assert.isTrue(library.cachedImages.exists(bitmapSymbol.path)); + #else + Assert.isFalse(bitmapData == bitmapSymbol.__createBitmapData(library)); + #end + } + + public function testBitmapUsedByShapeStaysReadableWhenUsedDirectlyFirst():Void + { + #if (lime && swf_hardware_bitmap_cache) + var library = createLibrary("shared-bitmap-test"); + var bitmapSymbol = addBitmap(library, 1, "shared-bitmap.png"); + var shapeSymbol = addShape(library, 2, bitmapSymbol.id); + + // done when the library loads, so the result does not depend on what is created first + library.__markShapeBitmapsReadable(); + + var directBitmapData = bitmapSymbol.__createBitmapData(library); + Assert.isTrue(directBitmapData.readable); + + var shapeBitmapData:BitmapData = cast shapeSymbol.__createObject(library).graphics.__commands.o[0]; + Assert.isTrue(shapeBitmapData == directBitmapData); + #else + Assert.isTrue(true); + #end + } + + public function testCompactShapeBitmapsStayReadable():Void + { + #if (lime && swf_hardware_bitmap_cache) + var library = createLibrary("compact-shape-bitmap-test"); + var bitmapSymbol = addBitmap(library, 1, "compact-shape-bitmap.png"); + + // shapes loaded from a library keep compact commands + var shapeSymbol = new AnimateShapeSymbol(); + shapeSymbol.id = 2; + var data:Array = [0, bitmapSymbol.id, null, true, true, 8, 0, 0, 7, 640, 0, 7, 640, 640, 7, 0, 640, 5]; + shapeSymbol.__setCompactCommands(data); + library.symbols.set(shapeSymbol.id, shapeSymbol); + + library.__markShapeBitmapsReadable(); + Assert.isTrue(bitmapSymbol.__createBitmapData(library).readable); + #else + Assert.isTrue(true); + #end + } + + private static function createLibrary(id:String):AnimateLibrary + { + var library = new AnimateLibrary(id, id); + library.bitmapSymbols = []; + library.symbols = new Map(); + return library; + } + + private static function addBitmap(library:AnimateLibrary, id:Int, path:String):AnimateBitmapSymbol + { + var symbol = new AnimateBitmapSymbol(); + symbol.id = id; + symbol.path = path; + library.cachedImages.set(path, new Image(null, 0, 0, 32, 32, 0xFF336699)); + library.symbols.set(id, symbol); + return symbol; + } + + private static function addShape(library:AnimateLibrary, id:Int, bitmapID:Int):AnimateShapeSymbol + { + var symbol = new AnimateShapeSymbol(); + symbol.id = id; + symbol.commands = [ + BeginBitmapFill(bitmapID, null, true, true), + MoveTo(0, 0), + LineTo(32, 0), + LineTo(32, 32), + LineTo(0, 32), + EndFill + ]; + library.symbols.set(id, symbol); + return symbol; + } + #end +} From ef66680c5ae2eb3623a5d0fab438c79732e18ce5 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 04:51:42 -0400 Subject: [PATCH 30/41] Dynamic text: cache the font found for each symbol (closes #42) Finding the embedded font for a dynamic text field looks through every registered font, and with regular expressions when there is no exact match. Each symbol now keeps the result until another font is registered. The matching itself is unchanged. From #42, without its class resolution caches, which saved little and could hide classes from SWF files loaded later. --- .../animate/AnimateDynamicTextSymbol.hx | 60 +++++++----- .../exporters/swflite/DynamicTextSymbol.hx | 60 +++++++----- tests/src/TestMain.hx | 3 + tests/src/tests/DynamicTextFontTest.hx | 94 +++++++++++++++++++ 4 files changed, 173 insertions(+), 44 deletions(-) create mode 100644 tests/src/tests/DynamicTextFontTest.hx diff --git a/src/swf/exporters/animate/AnimateDynamicTextSymbol.hx b/src/swf/exporters/animate/AnimateDynamicTextSymbol.hx index 3bda14751..1c99c7054 100644 --- a/src/swf/exporters/animate/AnimateDynamicTextSymbol.hx +++ b/src/swf/exporters/animate/AnimateDynamicTextSymbol.hx @@ -37,6 +37,10 @@ class AnimateDynamicTextSymbol extends AnimateSymbol public var x:Float; public var y:Float; + private var resolvedFontCount:Int = -1; + private var resolvedFontFound:Bool; + private var resolvedFontName:String; + public function new() { super(); @@ -99,40 +103,52 @@ class AnimateDynamicTextSymbol extends AnimateSymbol format.font = fontName; - var found = false; - - switch (format.font) + // fonts are only ever added, so the font to use only changes when another one is registered + var fontCount = Font.enumerateFonts().length; + if (resolvedFontCount != fontCount) { - case "_sans", "_serif", "_typewriter", "", null: - found = true; + var found = false; + + switch (format.font) + { + case "_sans", "_serif", "_typewriter", "", null: + found = true; + + default: + for (font in Font.enumerateFonts()) + { + if (font.fontName == format.font) + { + found = true; + break; + } + } + } + + if (!found) + { + var alpha = ~/[^a-zA-Z]+/g; + var spaces = ~/\s/g; - default: for (font in Font.enumerateFonts()) { - if (font.fontName == format.font) + if (alpha.replace(font.fontName, "").substr(0, fontName.length) == spaces.replace(fontName, "")) { + format.font = font.fontName; found = true; break; } } - } - - if (!found) - { - var alpha = ~/[^a-zA-Z]+/g; - var spaces = ~/\s/g; - - for (font in Font.enumerateFonts()) - { - if (alpha.replace(font.fontName, "").substr(0, fontName.length) == spaces.replace(fontName, "")) - { - format.font = font.fontName; - found = true; - break; - } } + + resolvedFontCount = fontCount; + resolvedFontFound = found; + resolvedFontName = format.font; } + format.font = resolvedFontName; + var found = resolvedFontFound; + if (found) { textField.embedFonts = true; diff --git a/src/swf/exporters/swflite/DynamicTextSymbol.hx b/src/swf/exporters/swflite/DynamicTextSymbol.hx index 971f0f126..6e3042cdf 100644 --- a/src/swf/exporters/swflite/DynamicTextSymbol.hx +++ b/src/swf/exporters/swflite/DynamicTextSymbol.hx @@ -37,6 +37,10 @@ class DynamicTextSymbol extends SWFSymbol public var x:Float; public var y:Float; + private var resolvedFontCount:Int = -1; + private var resolvedFontFound:Bool; + private var resolvedFontName:String; + public function new() { super(); @@ -99,40 +103,52 @@ class DynamicTextSymbol extends SWFSymbol format.font = fontName; - var found = false; - - switch (format.font) + // fonts are only ever added, so the font to use only changes when another one is registered + var fontCount = Font.enumerateFonts().length; + if (resolvedFontCount != fontCount) { - case "_sans", "_serif", "_typewriter", "", null: - found = true; + var found = false; + + switch (format.font) + { + case "_sans", "_serif", "_typewriter", "", null: + found = true; + + default: + for (font in Font.enumerateFonts()) + { + if (font.fontName == format.font) + { + found = true; + break; + } + } + } + + if (!found) + { + var alpha = ~/[^a-zA-Z]+/g; + var spaces = ~/\s/g; - default: for (font in Font.enumerateFonts()) { - if (font.fontName == format.font) + if (alpha.replace(font.fontName, "").substr(0, fontName.length) == spaces.replace(fontName, "")) { + format.font = font.fontName; found = true; break; } } - } - - if (!found) - { - var alpha = ~/[^a-zA-Z]+/g; - var spaces = ~/\s/g; - - for (font in Font.enumerateFonts()) - { - if (alpha.replace(font.fontName, "").substr(0, fontName.length) == spaces.replace(fontName, "")) - { - format.font = font.fontName; - found = true; - break; - } } + + resolvedFontCount = fontCount; + resolvedFontFound = found; + resolvedFontName = format.font; } + format.font = resolvedFontName; + var found = resolvedFontFound; + if (found) { textField.embedFonts = true; diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index b60ce704b..012f61caa 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -16,6 +16,9 @@ class TestMain extends Sprite runner.addCase(new tests.AnimateLibraryTest()); runner.addCase(new tests.AnimateShapeSymbolTest()); runner.addCase(new tests.AnimateTimelineTest()); + #if !flash + runner.addCase(new tests.DynamicTextFontTest()); + #end runner.addCase(new tests.ShapeExportTest()); runner.addCase(new tests.ShapesTest()); #if !flash diff --git a/tests/src/tests/DynamicTextFontTest.hx b/tests/src/tests/DynamicTextFontTest.hx new file mode 100644 index 000000000..8975a4910 --- /dev/null +++ b/tests/src/tests/DynamicTextFontTest.hx @@ -0,0 +1,94 @@ +package tests; + +import openfl.text.Font; +import openfl.text.TextField; +import swf.exporters.animate.AnimateDynamicTextSymbol; +import swf.exporters.animate.AnimateLibrary; +import swf.exporters.swflite.DynamicTextSymbol; +import swf.exporters.swflite.SWFLite; +import utest.Assert; +import utest.Test; + +@:access(swf.exporters.animate.AnimateDynamicTextSymbol) +@:access(swf.exporters.animate.AnimateLibrary) +@:access(swf.exporters.swflite.DynamicTextSymbol) +class DynamicTextFontTest extends Test +{ + // Flash fonts cannot be created and named at runtime + #if !flash + public function testAnimateFontRegisteredLater():Void + { + checkFontRegisteredLater("SwfTestAnimateLate", createAnimateTextField); + } + + public function testSWFLiteFontRegisteredLater():Void + { + checkFontRegisteredLater("SwfTestSWFLiteLate", createSWFLiteTextField); + } + + public function testAnimateFontMatching():Void + { + checkFontMatching("SwfTestAnimateFuzzy", createAnimateTextField); + } + + public function testSWFLiteFontMatching():Void + { + checkFontMatching("SwfTestSWFLiteFuzzy", createSWFLiteTextField); + } + + private static function checkFontRegisteredLater(fontName:String, createTextField:String->(Void->TextField)):Void + { + var create = createTextField(fontName); + Assert.isFalse(create().embedFonts); + + // the font found for a symbol is cached, until another font is registered + Font.registerFont(new Font(fontName)); + var textField = create(); + Assert.isTrue(textField.embedFonts); + Assert.equals(fontName, textField.defaultTextFormat.font); + } + + private static function checkFontMatching(fontName:String, createTextField:String->(Void->TextField)):Void + { + // a font name can match the start of an embedded font name, ignoring other characters + Font.registerFont(new Font(fontName + "-Bold")); + var create = createTextField(fontName); + + for (i in 0...2) + { + var textField = create(); + Assert.isTrue(textField.embedFonts); + Assert.equals(fontName + "-Bold", textField.defaultTextFormat.font); + } + } + + private static function createAnimateTextField(fontName:String):Void->TextField + { + var library = new AnimateLibrary("dynamic-text-font-test", "dynamic-text-font-test"); + library.symbols = new Map(); + + var symbol = new AnimateDynamicTextSymbol(); + symbol.fontName = fontName; + symbol.fontHeight = 240; + symbol.fontID = 0; + symbol.width = symbol.height = symbol.x = symbol.y = 0; + symbol.border = symbol.html = symbol.input = symbol.multiline = symbol.password = symbol.wordWrap = false; + symbol.selectable = true; + return function() return symbol.__createObject(library); + } + + private static function createSWFLiteTextField(fontName:String):Void->TextField + { + var swf = new SWFLite(); + + var symbol = new DynamicTextSymbol(); + symbol.fontName = fontName; + symbol.fontHeight = 240; + symbol.fontID = 0; + symbol.width = symbol.height = symbol.x = symbol.y = 0; + symbol.border = symbol.html = symbol.input = symbol.multiline = symbol.password = symbol.wordWrap = false; + symbol.selectable = true; + return function() return symbol.__createObject(swf); + } + #end +} From 329307d7f7ff4cf1e2b81446230a186549e8a7d8 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 04:56:42 -0400 Subject: [PATCH 31/41] Update the 3.5.0 CHANGELOG --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0138917b..faa179263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,19 +1,23 @@ -3.5.0 (10/26/2026) +3.5.0 (10/27/2026) ------------------ * Added `DisplayObject.metaData` support for Animate libraries * Added `lineGradientStyle` support for SWF shapes in Animate, SWFLite and runtime shapes * Added caching of generated classes for Flash and AIR SWF libraries * Added export of linked MP3 and uncompressed sounds in Animate libraries (MP3 on native targets requires Lime 8.4 or newer) +* Added the `swf_compact_animate_timelines` define, to keep Animate timelines in compact buffers instead of one object per frame and event +* Added the `swf_hardware_bitmap_cache` define, to share Animate bitmaps and release their CPU images once uploaded * Improved `generate="true"` on Animate libraries to compile the generated classes, so symbols are created as their linkage class * Improved Animate shape memory usage by keeping commands compact until a shape is first drawn * Improved SWF exporter performance for shapes with many drawing commands * Improved timeline performance by caching instance field lists per class +* Improved dynamic text performance by caching the embedded font found for each symbol * Changed `AnimateShapeSymbol.commands` to be `null` for shapes loaded from an Animate library * Fixed Animate and SWFLite exports of SWF files without ABC or `SymbolClass` tags, such as AS2 SWF files using `ExportAssets` * Fixed `SWFLibrary` failing to load non-embedded SWF files * Fixed processing SWF files with linked Speex sounds * Fixed generated Animate classes using another library's symbol when several libraries export the same class +* Fixed generated Animate classes not being used, or extending the wrong base class, when the library has a `prefix` * Fixed the document class not being applied to the root timeline when it is not named `*_fla.MainTimeline` * Fixed clipping characters being displayed when nothing is in their mask range * Fixed reused timeline instances keeping an outdated `clipDepth` @@ -25,6 +29,7 @@ * Fixed C++ compile error in `AnimateLibraryExporter` class generation * Fixed Animate libraries created without a UUID being registered under a `null` key * Fixed processed Animate ZIP files not being closed after writing +* Fixed the command-line tools showing the version of Lime instead of swf 3.4.0 (11/11/2025) From 1658825731aef0056884488c27a5f01b0e5bbfea Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 15:15:16 -0400 Subject: [PATCH 32/41] Declare the format dependency, and use https URLs The SWF parser imports format, but haxelib.json did not declare it, so installing swf from haxelib did not install it. CI now checks that haxelib resolves format through swf. Also refreshes the haxelib release note, notes the new LineGradientStyle enum constructors in the CHANGELOG, and runs CI with Haxe 4.3.7. --- .github/workflows/main.yml | 18 +++++++++++------- CHANGELOG.md | 2 ++ README.md | 2 +- haxelib.json | 9 ++++++--- 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 64ffebdde..b98f3719e 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -42,7 +42,7 @@ jobs: needs: package-haxelib strategy: matrix: - haxe-version: [4.0.5, 4.1.5, 4.2.5, 4.3.6] + haxe-version: [4.0.5, 4.1.5, 4.2.5, 4.3.7] runs-on: ubuntu-latest steps: @@ -97,7 +97,7 @@ jobs: unit-test-neko: strategy: matrix: - haxe-version: [3.4.7, 4.0.5, 4.1.5, 4.2.5, 4.3.6] + haxe-version: [3.4.7, 4.0.5, 4.1.5, 4.2.5, 4.3.7] runs-on: ubuntu-latest env: SDL_VIDEODRIVER: "dummy" @@ -139,7 +139,7 @@ jobs: - uses: krdlab/setup-haxe@v2 with: - haxe-version: 4.3.6 + haxe-version: 4.3.7 - name: Set HAXEPATH run: | @@ -161,7 +161,7 @@ jobs: unit-test-tools: strategy: matrix: - haxe-version: [4.2.5, 4.3.6] + haxe-version: [4.2.5, 4.3.7] runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -183,6 +183,10 @@ jobs: haxelib install utest --quiet haxelib dev swf ${{ github.workspace }} + - name: Check haxelib dependencies + run: | + haxelib path swf | grep -q -- "-D format=" + - name: Rebuild swf run.n run: | haxe rebuild.hxml @@ -197,7 +201,7 @@ jobs: strategy: matrix: # HashLink is not supported by Haxe 3 - haxe-version: [4.0.5, 4.1.5, 4.2.5, 4.3.6] + haxe-version: [4.0.5, 4.1.5, 4.2.5, 4.3.7] # AL init fails on both windows and ubuntu runs-on: macos-latest steps: @@ -227,7 +231,7 @@ jobs: unit-test-air: strategy: matrix: - haxe-version: [3.4.7, 4.0.5, 4.1.5, 4.2.5, 4.3.6] + haxe-version: [3.4.7, 4.0.5, 4.1.5, 4.2.5, 4.3.7] runs-on: windows-latest steps: - uses: actions/checkout@v6 @@ -267,7 +271,7 @@ jobs: - uses: krdlab/setup-haxe@v2 with: - haxe-version: "4.3.6" + haxe-version: "4.3.7" - uses: joshtynjala/setup-adobe-air-action@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index faa179263..ded734b4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ * Improved timeline performance by caching instance field lists per class * Improved dynamic text performance by caching the embedded font found for each symbol * Changed `AnimateShapeSymbol.commands` to be `null` for shapes loaded from an Animate library +* Changed `ShapeCommand` and `AnimateShapeCommand` to include `LineGradientStyle`, so exhaustive `switch` statements over them need another case +* Changed haxelib.json to declare the `format` dependency * Fixed Animate and SWFLite exports of SWF files without ABC or `SymbolClass` tags, such as AS2 SWF files using `ExportAssets` * Fixed `SWFLibrary` failing to load non-embedded SWF files * Fixed processing SWF files with linked Speex sounds diff --git a/README.md b/README.md index dd9667498..fe5df290b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE.md) [![Haxelib Version](https://img.shields.io/github/tag/openfl/swf.svg?style=flat&label=haxelib)](http://lib.haxe.org/p/swf) +[![MIT License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE.md) [![Haxelib Version](https://img.shields.io/github/tag/openfl/swf.svg?style=flat&label=haxelib)](https://lib.haxe.org/p/swf) SWF === diff --git a/haxelib.json b/haxelib.json index f1ac9d6a8..3cc0db212 100644 --- a/haxelib.json +++ b/haxelib.json @@ -1,15 +1,18 @@ { "name": "swf", - "url": "http://github.com/openfl/swf", + "url": "https://github.com/openfl/swf", "license": "MIT", "tags": [ "openfl", "swf", "animation", "flash", "assets", "swc" ], "description": "Provides support for parsing and displaying SWF content", "version": "3.5.0", - "releasenote": "Line gradient styles, lower Animate shape memory use, clipping and exporter fixes", + "releasenote": "Linked sounds, line gradients, generated class fixes, opt-in compact timelines and bitmap cache, many timeline and exporter fixes", "contributors": [ "singmajesty", "bowlerhat", "Dimensionscape" ], - "classPath": "src" + "classPath": "src", + "dependencies": { + "format": "" + } } From 013cdf0991c5139b58421a733f8677d230193ec4 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 15:32:05 -0400 Subject: [PATCH 33/41] Size SWF bit fields in binary when publishing calculateMaxBits() counted 8 bits for each decimal digit of a value, so larger values overflowed the bit count fields when publishing: from 10 in color transforms, from 100 in shape edges, and from 1000 in matrices and rectangles. A signed -1 also got 0 bits. It now returns the smallest size that holds every value. --- CHANGELOG.md | 1 + src/swf/utils/BitArray.hx | 41 +++++---------- tests/src/TestMain.hx | 1 + tests/src/tests/SWFDataTest.hx | 96 ++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 29 deletions(-) create mode 100644 tests/src/tests/SWFDataTest.hx diff --git a/CHANGELOG.md b/CHANGELOG.md index ded734b4f..27d28f789 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ * Fixed `clippingLayer` being set on the Flash target * Fixed SWFLite timelines failing to compile on the Flash target * Fixed `DefineFont2` and `DefineFont3` parsing reading past the tag for fonts without layout +* Fixed `SWFRoot.publish()` sizing bit fields from the decimal digits of each value, which wrote broken matrices, rectangles, color transforms and shapes * Fixed generated Flash/AIR classes being written to the wrong package directory * Fixed C++ compile error in `AnimateLibraryExporter` class generation * Fixed Animate libraries created without a UUID being registered under a `null` key diff --git a/src/swf/utils/BitArray.hx b/src/swf/utils/BitArray.hx index 269da30f2..0789024d9 100644 --- a/src/swf/utils/BitArray.hx +++ b/src/swf/utils/BitArray.hx @@ -1,7 +1,6 @@ package swf.utils; import openfl.utils.ByteArray; -import haxe.crypto.BaseCode; class BitArray extends ByteArrayData { @@ -115,40 +114,24 @@ class BitArray extends ByteArrayData public function calculateMaxBits(signed:Bool, values:Array):Int { - var b:Int = 0; - var vmax:Int = SWFData.MIN_INT_VALUE; - if (!signed) + var bits:Int = 0; + for (value in values) { - for (usvalue in values) + if (value == 0) { - b |= usvalue; + continue; } - } - else - { - for (svalue in values) + // A signed value needs the bits of its magnitude plus a sign bit + var magnitude:Int = (signed && value < 0) ? ~value : value; + var valueBits:Int = signed ? 1 : 0; + while (magnitude != 0) { - if (svalue >= 0) - { - b |= svalue; - } - else - { - b |= ~svalue << 1; - } - if (vmax < svalue) - { - vmax = svalue; - } + magnitude >>>= 1; + valueBits++; } - } - var bits:Int = 0; - if (b > 0) - { - bits = BaseCode.encode(Std.string(b), "01").length; - if (signed && vmax > 0 && BaseCode.encode(Std.string(vmax), "01").length >= bits) + if (valueBits > bits) { - bits++; + bits = valueBits; } } return bits; diff --git a/tests/src/TestMain.hx b/tests/src/TestMain.hx index 012f61caa..923b5395f 100644 --- a/tests/src/TestMain.hx +++ b/tests/src/TestMain.hx @@ -21,6 +21,7 @@ class TestMain extends Sprite #end runner.addCase(new tests.ShapeExportTest()); runner.addCase(new tests.ShapesTest()); + runner.addCase(new tests.SWFDataTest()); #if !flash runner.addCase(new tests.SWFLiteExporterTest()); #end diff --git a/tests/src/tests/SWFDataTest.hx b/tests/src/tests/SWFDataTest.hx new file mode 100644 index 000000000..d4d04c49b --- /dev/null +++ b/tests/src/tests/SWFDataTest.hx @@ -0,0 +1,96 @@ +package tests; + +import swf.SWFData; +import swf.data.SWFColorTransformWithAlpha; +import swf.data.SWFMatrix; +import swf.data.SWFRectangle; +import utest.Assert; +import utest.Test; + +class SWFDataTest extends Test +{ + public function testCalculateMaxBits():Void + { + var data = new SWFData(); + Assert.equals(0, data.calculateMaxBits(false, [0])); + Assert.equals(1, data.calculateMaxBits(false, [1])); + Assert.equals(8, data.calculateMaxBits(false, [255])); + Assert.equals(10, data.calculateMaxBits(false, [1000])); + Assert.equals(0, data.calculateMaxBits(true, [0, 0])); + Assert.equals(2, data.calculateMaxBits(true, [1])); + Assert.equals(1, data.calculateMaxBits(true, [-1, 0])); + Assert.equals(2, data.calculateMaxBits(true, [-2])); + Assert.equals(12, data.calculateMaxBits(true, [2047, -2048])); + Assert.equals(13, data.calculateMaxBits(true, [2048, 0])); + Assert.equals(13, data.calculateMaxBits(true, [0, -2049])); + } + + public function testMatrixRoundTrip():Void + { + var matrix = new SWFMatrix(); + matrix.scaleX = 1.5; + matrix.scaleY = 0.5; + matrix.rotateSkew0 = 0.25; + matrix.rotateSkew1 = -0.25; + matrix.translateX = 2000; + matrix.translateY = -3000; + + var data = new SWFData(); + data.writeMATRIX(matrix); + data.position = 0; + Assert.equals(matrix.toString(), data.readMATRIX().toString()); + + var translation = new SWFMatrix(); + translation.translateX = -1; + data = new SWFData(); + data.writeMATRIX(translation); + data.position = 0; + Assert.equals(translation.toString(), data.readMATRIX().toString()); + } + + public function testRectangleRoundTrip():Void + { + var rect = new SWFRectangle(); + rect.xmin = -20; + rect.xmax = 11000; + rect.ymin = 0; + rect.ymax = 8000; + + var data = new SWFData(); + data.writeRECT(rect); + data.position = 0; + var result = data.readRECT(); + Assert.equals(-20, result.xmin); + Assert.equals(11000, result.xmax); + Assert.equals(0, result.ymin); + Assert.equals(8000, result.ymax); + } + + public function testColorTransformRoundTrip():Void + { + var colorTransform = new SWFColorTransformWithAlpha(); + colorTransform.hasMultTerms = true; + colorTransform.hasAddTerms = true; + colorTransform.rMult = 256; + colorTransform.gMult = 128; + colorTransform.bMult = 0; + colorTransform.aMult = 256; + colorTransform.rAdd = -255; + colorTransform.gAdd = 255; + colorTransform.bAdd = 0; + colorTransform.aAdd = 0; + + var data = new SWFData(); + data.writeCXFORMWITHALPHA(colorTransform); + data.position = 0; + var result = data.readCXFORMWITHALPHA(); + Assert.equals(256, result.rMult); + Assert.equals(128, result.gMult); + Assert.equals(0, result.bMult); + Assert.equals(256, result.aMult); + Assert.equals(-255, result.rAdd); + Assert.equals(255, result.gAdd); + Assert.equals(0, result.bAdd); + Assert.equals(0, result.aAdd); + } +} From 480a9ea25fac2048b7df90ced4a4217a7a70ebee Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 15:32:06 -0400 Subject: [PATCH 34/41] Test Animate exports by loading and playing them The tools suite exports a SWF built in code with the Animate exporter, loads the result with AnimateLibrary and checks each frame, including rewinding, then runs again with compact timelines. --- .github/workflows/main.yml | 6 + tests/tools/src/ToolsTestMain.hx | 2 +- tests/tools/src/tools/RoundTripTest.hx | 173 +++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 tests/tools/src/tools/RoundTripTest.hx diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b98f3719e..fbede03f4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -197,6 +197,12 @@ jobs: haxe tools.hxml neko bin/tools.n + - name: Run tools tests with compact timelines + working-directory: tests/tools + run: | + haxe tools.hxml -D swf_compact_animate_timelines + neko bin/tools.n + unit-test-hashlink: strategy: matrix: diff --git a/tests/tools/src/ToolsTestMain.hx b/tests/tools/src/ToolsTestMain.hx index c1f89cacf..44d6c7525 100644 --- a/tests/tools/src/ToolsTestMain.hx +++ b/tests/tools/src/ToolsTestMain.hx @@ -3,6 +3,6 @@ class ToolsTestMain { public static function main():Void { - utest.UTest.run([new tools.AnimateLibraryExporterTest(), new tools.ToolsTest()]); + utest.UTest.run([new tools.AnimateLibraryExporterTest(), new tools.RoundTripTest(), new tools.ToolsTest()]); } } diff --git a/tests/tools/src/tools/RoundTripTest.hx b/tests/tools/src/tools/RoundTripTest.hx new file mode 100644 index 000000000..1a8b88e02 --- /dev/null +++ b/tests/tools/src/tools/RoundTripTest.hx @@ -0,0 +1,173 @@ +package tools; + +import openfl.display.DisplayObject; +import openfl.display.MovieClip; +import swf.data.SWFMatrix; +import swf.data.SWFRectangle; +import swf.exporters.AnimateLibraryExporter; +import swf.exporters.animate.AnimateLibrary; +import swf.tags.TagDefineShape; +import swf.tags.TagDefineSprite; +import swf.tags.TagEnd; +import swf.tags.TagPlaceObject2; +import swf.tags.TagRemoveObject2; +import swf.tags.TagShowFrame; +import sys.io.File; +import tests.SWFBuilder; +import tests.ShapeBuilder; +import utest.Assert; +import utest.Async; +import utest.Test; + +// Exports a SWF with the Animate exporter, loads the result with AnimateLibrary, and plays it, +// so the runtime is tested with the data the exporter really writes +@:access(openfl.display.DisplayObject) +class RoundTripTest extends Test +{ + public function testTimelineRoundTrip(async:Async):Void + { + var sprite = new TagDefineSprite(); + sprite.characterId = 10; + sprite.frameCount = 4; + var frames:Array> = [ + // a named child + [place(1, 1, "first")], + // the same child, moved + [move(1, 2000)], + // another character at the same depth + [remove(1), place(1, 2)], + // a clipping character above it, with a masked child + [clip(2, 1, 3), place(3, 2)] + ]; + for (frame in frames) + { + for (tag in frame) + { + sprite.tags.push(tag); + } + sprite.tags.push(new TagShowFrame()); + } + sprite.tags.push(new TagEnd()); + + var swf = new SWFBuilder().add(square(1, 400, 0xFF0000)).add(square(2, 800, 0x0000FF)).add(sprite).link(10, "RoundTripClip").parse(); + var path = AnimateLibraryExporterTest.tempPath("round-trip.zip"); + new AnimateLibraryExporter(swf, path); + + AnimateLibrary.loadFromBytes(File.getBytes(path)).onComplete(function(library) + { + var clip = library.getMovieClip("RoundTripClip"); + Assert.notNull(clip); + Assert.equals(4, clip.totalFrames); + + var expected = [ + "first 20x20 at 0", + "first 20x20 at 100", + "40x40 at 0", + "40x40 at 0 | 20x20 at 0 mask | 40x40 at 0 masked" + ]; + for (frame in 1...5) + { + clip.gotoAndStop(frame); + Assert.equals(expected[frame - 1], describe(clip), "frame " + frame); + } + + // rewinding rebuilds the earlier frames + for (frame in [2, 1]) + { + clip.gotoAndStop(frame); + Assert.equals(expected[frame - 1], describe(clip), "frame " + frame + " after rewinding"); + } + + library.unload(); + async.done(); + }).onError(function(error) + { + Assert.fail("Could not load the exported library: " + error); + async.done(); + }); + } + + private static function describe(clip:MovieClip):String + { + var children = []; + for (i in 0...clip.numChildren) + { + var child = clip.getChildAt(i); + var name = StringTools.startsWith(child.name, "instance") ? "" : child.name + " "; + var flags = (child.__isMask == true ? " mask" : "") + (isMasked(child) ? " masked" : ""); + children.push(name + Math.round(child.width) + "x" + Math.round(child.height) + " at " + Math.round(child.x) + flags); + } + return children.join(" | "); + } + + private static function isMasked(child:DisplayObject):Bool + { + #if (openfl >= "9.5.0") + return child.mask != null || child.clippingLayer != null; + #else + return child.mask != null; + #end + } + + private static function square(id:Int, size:Int, rgb:Int):TagDefineShape + { + var tag = new TagDefineShape(); + tag.characterId = id; + tag.shapeBounds = new SWFRectangle(); + tag.shapeBounds.xmin = 0; + tag.shapeBounds.ymin = 0; + tag.shapeBounds.xmax = size; + tag.shapeBounds.ymax = size; + tag.shapes = new ShapeBuilder() + .addSolidFill(0xFF000000 | rgb) + .move(0, 0, 0, 1, 0) + .lineTo(size, 0) + .lineTo(0, size) + .lineTo(-size, 0) + .lineTo(0, -size) + .end(); + return tag; + } + + private static function place(depth:Int, characterId:Int, name:String = null):TagPlaceObject2 + { + var tag = new TagPlaceObject2(); + tag.depth = depth; + tag.hasCharacter = true; + tag.characterId = characterId; + tag.hasMatrix = true; + tag.matrix = new SWFMatrix(); + if (name != null) + { + tag.hasName = true; + tag.instanceName = name; + } + return tag; + } + + private static function move(depth:Int, x:Int):TagPlaceObject2 + { + var tag = new TagPlaceObject2(); + tag.depth = depth; + tag.hasMove = true; + tag.hasMatrix = true; + tag.matrix = new SWFMatrix(); + tag.matrix.translateX = x; + return tag; + } + + private static function clip(depth:Int, characterId:Int, clipDepth:Int):TagPlaceObject2 + { + var tag = place(depth, characterId); + tag.hasClipDepth = true; + tag.clipDepth = clipDepth; + return tag; + } + + private static function remove(depth:Int):TagRemoveObject2 + { + var tag = new TagRemoveObject2(); + tag.depth = depth; + return tag; + } +} From 6ec4268671519810378f716209cfc932210cb6e8 Mon Sep 17 00:00:00 2001 From: Chris Speciale Date: Tue, 27 Oct 2026 15:32:07 -0400 Subject: [PATCH 35/41] Run the tests on HTML5 OpenFL 9.4 and later only start utest apps on HTML5 with openfl_enable_utest_legacy_mode, so the HTML5 tests never ran. CI now runs them in headless Chrome, with and without the opt-in defines. Howler plays sounds on HTML5 without exposing their samples, so the sound tests check for its sound there instead of the sample rate. --- .github/workflows/main.yml | 38 +++++++++++++++++++++++++++ tests/project.xml | 2 ++ tests/src/tests/AnimateLibraryTest.hx | 21 ++++++++++----- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index fbede03f4..6d564edc0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -203,6 +203,44 @@ jobs: haxe tools.hxml -D swf_compact_animate_timelines neko bin/tools.n + unit-test-html5: + strategy: + matrix: + flags: ["", "-Dswf_compact_animate_timelines -Dswf_hardware_bitmap_cache"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: krdlab/setup-haxe@v2 + with: + haxe-version: 4.3.7 + + - name: Set HAXEPATH + run: | + echo "HAXEPATH=$HAXE_STD_PATH/.." >> $GITHUB_ENV + + - name: Install Haxe dependencies + run: | + haxelib install format --quiet + haxelib install lime --quiet + haxelib install openfl --quiet + haxelib install utest --quiet + haxelib dev swf ${{ github.workspace }} + + - name: Build tests for HTML5 + working-directory: tests + run: | + haxelib run openfl build html5 ${{ matrix.flags }} + + - name: Run tests in headless Chrome + working-directory: tests + run: | + python3 -m http.server 8000 --bind 127.0.0.1 --directory bin/html5/bin & + sleep 2 + google-chrome --headless=new --no-sandbox --disable-gpu --virtual-time-budget=10000 --dump-dom http://127.0.0.1:8000/index.html > report.html + sed -e 's/<[^>]*>/ /g' report.html + grep -q "TEST OK" report.html + unit-test-hashlink: strategy: matrix: diff --git a/tests/project.xml b/tests/project.xml index e7b6bd3ac..28f1b1e1f 100644 --- a/tests/project.xml +++ b/tests/project.xml @@ -13,5 +13,7 @@ + +