@flighthq/swf 0.3.0 → 0.3.1-next.320.fe56fc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/dist/contract.d.ts +1 -0
  2. package/dist/contract.d.ts.map +1 -1
  3. package/dist/contract.js +1 -0
  4. package/dist/contract.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +1 -0
  8. package/dist/index.js.map +1 -1
  9. package/dist/swfDocument.d.ts +5 -5
  10. package/dist/swfDocument.d.ts.map +1 -1
  11. package/dist/swfDocument.js +359 -102
  12. package/dist/swfDocument.js.map +1 -1
  13. package/dist/swfFilter.d.ts +2 -2
  14. package/dist/swfFilter.d.ts.map +1 -1
  15. package/dist/swfFilter.js +24 -2
  16. package/dist/swfFilter.js.map +1 -1
  17. package/dist/swfFrameAction.d.ts +2 -2
  18. package/dist/swfFrameAction.d.ts.map +1 -1
  19. package/dist/swfFrameAction.js +13 -6
  20. package/dist/swfFrameAction.js.map +1 -1
  21. package/dist/swfFrameActionTestHelper.d.ts +2 -0
  22. package/dist/swfFrameActionTestHelper.d.ts.map +1 -0
  23. package/dist/swfFrameActionTestHelper.js +99 -0
  24. package/dist/swfFrameActionTestHelper.js.map +1 -0
  25. package/dist/swfImageDecoder.d.ts +4 -0
  26. package/dist/swfImageDecoder.d.ts.map +1 -0
  27. package/dist/swfImageDecoder.js +21 -0
  28. package/dist/swfImageDecoder.js.map +1 -0
  29. package/dist/swfMorphShape.d.ts +2 -1
  30. package/dist/swfMorphShape.d.ts.map +1 -1
  31. package/dist/swfMorphShape.js +18 -7
  32. package/dist/swfMorphShape.js.map +1 -1
  33. package/dist/swfText.d.ts +2 -2
  34. package/dist/swfText.d.ts.map +1 -1
  35. package/dist/swfText.js +15 -2
  36. package/dist/swfText.js.map +1 -1
  37. package/package.json +24 -22
  38. package/src/swfDocument.test.ts +1527 -163
  39. package/src/swfFilter.test.ts +53 -0
  40. package/src/swfFrameAction.test.ts +26 -97
  41. package/src/swfImageDecoder.test.ts +53 -0
  42. package/src/swfImageResources.test.ts +14 -17
  43. package/src/swfMorphShape.test.ts +18 -0
@@ -5,6 +5,8 @@ import {
5
5
  } from '@flighthq/compression/contract';
6
6
  import { createGlyphRasterizerBackendFromGlyphOutlineSource } from '@flighthq/font/contract';
7
7
  import { createGlyphAtlas, getGlyphAtlasEntry } from '@flighthq/glyphatlas/contract';
8
+ import { clearImageDecoders } from '@flighthq/image-codec/contract';
9
+ import { collectImportDiagnostics } from '@flighthq/importdiagnostics/contract';
8
10
  import {
9
11
  getMovieClipCurrentFrame,
10
12
  getMovieClipCurrentLabel,
@@ -26,6 +28,7 @@ import {
26
28
  import {
27
29
  createScene2DDocumentFromBytes,
28
30
  createScene2DDocumentImporterRegistry,
31
+ loadScene2DImageResources,
29
32
  resolveScene2DResources,
30
33
  } from '@flighthq/scene2d-resources/contract';
31
34
  import { createDisplayObject } from '@flighthq/scene2d/contract';
@@ -37,6 +40,7 @@ import type {
37
40
  TimelineAudioCue,
38
41
  MorphShape,
39
42
  MovieClip,
43
+ Node2D,
40
44
  RichText,
41
45
  Scale9Shape,
42
46
  Shape,
@@ -50,6 +54,7 @@ import {
50
54
  Compression,
51
55
  DisplayObjectKind,
52
56
  ImageResourceReferenceKind,
57
+ ImportDiagnosticSeverity,
53
58
  MorphShapeKind,
54
59
  MovieClipKind,
55
60
  ResourceResolutionState,
@@ -67,10 +72,18 @@ import {
67
72
  readSwfExportedSymbolNames,
68
73
  registerSwfScene2DDocumentImporter,
69
74
  } from './swfDocument';
75
+ import { buildFrameScriptAbc } from './swfFrameActionTestHelper';
76
+ import { registerSwfImageDecoders } from './swfImageDecoder';
70
77
  import { ShapeWriter } from './swfShapeTestHelper';
71
78
 
72
- beforeEach(() => unregisterDecompressor(Compression.Deflate));
73
- afterEach(() => unregisterDecompressor(Compression.Deflate));
79
+ beforeEach(() => {
80
+ clearImageDecoders();
81
+ unregisterDecompressor(Compression.Deflate);
82
+ });
83
+ afterEach(() => {
84
+ clearImageDecoders();
85
+ unregisterDecompressor(Compression.Deflate);
86
+ });
74
87
 
75
88
  describe('createGlyphOutlineSourcesFromSwf', () => {
76
89
  it('funnels a DefineFont2 outline and code table through the font adapter into glyphatlas', () => {
@@ -1193,8 +1206,7 @@ describe('createScene2DFromSwf', () => {
1193
1206
  expect(drawn.data.commands.filter((token) => token === 'lineTo')).toHaveLength(2);
1194
1207
  });
1195
1208
 
1196
- it('resolves a lossless bitmap fill to pixels at import, with no image resource left to load', () => {
1197
- registerDeflateDecompressor();
1209
+ it('emits a lossless bitmap reference synchronously and resolves it only in the async resource pass', async () => {
1198
1210
  const art = new ShapeWriter();
1199
1211
  art.writeFillStyleCount(1);
1200
1212
  art.writeBitmapFillStyle(0x41, 9, 20);
@@ -1216,18 +1228,27 @@ describe('createScene2DFromSwf', () => {
1216
1228
  createTag(TAG_END),
1217
1229
  ]),
1218
1230
  );
1219
- unregisterDecompressor(Compression.Deflate);
1220
-
1221
1231
  const drawn = getNodeChildren(document!.root)[0] as Shape;
1222
1232
  expect(drawn.data.commands[0]).toBe('beginTextureFill');
1223
- // A lossless payload is not an image file, so it resolves here rather than through @flighthq/image —
1224
- // which is why it leaves nothing on the document's image-resource contract.
1225
1233
  const texture = drawn.data.commands[2] as Texture2D;
1234
+ const reference = document!.imageResources[0];
1235
+ expect(document!.imageResources).toHaveLength(1);
1236
+ expect(reference.mimeType).toBe('image/x-swf-lossless');
1237
+ expect(reference.kind === ImageResourceReferenceKind.Embedded && reference.alphaType).toBe('opaque');
1238
+ expect(reference.textures).toEqual([texture]);
1239
+ expect(getTextureSource(texture)).toBeNull();
1240
+
1241
+ // Registration is caller-owned and happens after parsing; neither parsing nor reference creation
1242
+ // secretly installs a decoder or starts async work.
1243
+ registerDeflateDecompressor();
1244
+ registerSwfImageDecoders();
1245
+ const resources = await loadScene2DImageResources(document!);
1246
+
1247
+ expect(resources.resolved).toEqual([reference]);
1226
1248
  expectLosslessTexturePixel(texture);
1227
- expect(document!.imageResources).toEqual([]);
1228
1249
  });
1229
1250
 
1230
- it('leaves a lossless bitmap sourceless when no decompressor is registered', () => {
1251
+ it('parses lossless bitmap reference data without requiring a registered decompressor', () => {
1231
1252
  const art = new ShapeWriter();
1232
1253
  art.writeFillStyleCount(1);
1233
1254
  art.writeBitmapFillStyle(0x41, 9, 20);
@@ -1253,6 +1274,44 @@ describe('createScene2DFromSwf', () => {
1253
1274
  const drawn = getNodeChildren(document!.root)[0] as Shape;
1254
1275
  expect(drawn.data.commands[0]).toBe('beginTextureFill');
1255
1276
  expect(getTextureSource(drawn.data.commands[2] as Texture2D)).toBeNull();
1277
+ expect(document!.imageResources).toHaveLength(1);
1278
+ expect(document!.imageResources[0].state).toBe(ResourceResolutionState.Unresolved);
1279
+ });
1280
+
1281
+ it('carries premultiplied lossless-alpha bytes through the reference into the resolved Bitmap', async () => {
1282
+ const art = new ShapeWriter();
1283
+ art.writeFillStyleCount(1);
1284
+ art.writeBitmapFillStyle(0x41, 9, 20);
1285
+ art.writeLineStyleCount(0);
1286
+ art.writeStyleBits(1, 0);
1287
+ art.writeStyleChange({ fill1: 1, moveToX: 0, moveToY: 0 });
1288
+ art.writeStraightEdge(800, 0);
1289
+ art.writeStraightEdge(0, 800);
1290
+ art.writeEndShape();
1291
+ const pixels = losslessPayload(5, 1, 1, storedDeflate([0x80, 0x40, 0x20, 0x10]));
1292
+ const document = createScene2DFromSwf(
1293
+ createSwf([
1294
+ createTag(TAG_DEFINE_BITS_LOSSLESS_2, joinBytes(uint16(9), pixels)),
1295
+ createTag(TAG_DEFINE_SHAPE_3, joinBytes(uint16(7), createRectangle(0, 800, 0, 800), art.toBytes())),
1296
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(7))),
1297
+ createTag(TAG_SHOW_FRAME),
1298
+ createTag(TAG_END),
1299
+ ]),
1300
+ )!;
1301
+ const reference = document.imageResources[0];
1302
+ expect(reference.kind === ImageResourceReferenceKind.Embedded && reference.alphaType).toBe('premultiplied');
1303
+
1304
+ registerDeflateDecompressor();
1305
+ registerSwfImageDecoders();
1306
+ await loadScene2DImageResources(document);
1307
+
1308
+ const drawn = getNodeChildren(document.root)[0] as Shape;
1309
+ const source = getTextureSource(drawn.data.commands[2] as Texture2D);
1310
+ expect(source?.kind).toBe(BitmapTextureSourceKind);
1311
+ if (source?.kind !== BitmapTextureSourceKind) throw new Error('expected a lossless bitmap source');
1312
+ const bitmap = source as Bitmap;
1313
+ expect(bitmap.alphaType).toBe('premultiplied');
1314
+ expect([...bitmap.data]).toEqual([0x40, 0x20, 0x10, 0x80]);
1256
1315
  });
1257
1316
 
1258
1317
  it('samples one bitmap character through a texture per sampling variant', () => {
@@ -2439,51 +2498,128 @@ describe('createScene2DFromSwf', () => {
2439
2498
  });
2440
2499
  });
2441
2500
 
2442
- describe('createScene2DFromSwf morph bounds', () => {
2443
- it('reports the box the morph occupies at its ratio, not the union of both endpoints', () => {
2444
- const startEdges = morphBox(200);
2445
- const endEdges = morphBox(400);
2446
- const styles = joinBytes(
2447
- new Uint8Array([1, 0x00, 0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff]),
2448
- new Uint8Array([0]),
2449
- );
2501
+ describe('createScene2DFromSwf import diagnostics', () => {
2502
+ it('reports an unreadable container as a Reject naming which check refused it', () => {
2503
+ const notSwf = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]);
2504
+ const diagnostics = collectImportDiagnostics((sink) => {
2505
+ expect(createScene2DFromSwf(notSwf, sink)).toBeNull();
2506
+ });
2507
+
2508
+ expect(diagnostics.map((entry) => entry.kind)).toEqual(['swf.invalid-signature']);
2509
+ expect(diagnostics[0].severity).toBe(ImportDiagnosticSeverity.Reject);
2510
+ expect(diagnostics[0].origin).toBe('uncompressSwfSource');
2511
+ });
2512
+
2513
+ it('separates an unregistered decompressor from a corrupt body, which share one null sentinel', () => {
2514
+ const uncompressed = createSwf([createTag(TAG_SHOW_FRAME), createTag(TAG_END)]);
2515
+ const compressed = joinBytes(uncompressed.subarray(0, 8), uncompressed.subarray(8));
2516
+ compressed[0] = 0x43;
2517
+
2518
+ // Nothing registered: the file is readable after one registration, which is not the same failure as
2519
+ // a corrupt stream even though both come back null.
2520
+ const unregistered = collectImportDiagnostics((sink) => {
2521
+ expect(createScene2DFromSwf(compressed, sink)).toBeNull();
2522
+ });
2523
+ expect(unregistered.map((entry) => entry.kind)).toEqual(['swf.no-decompressor-registered']);
2524
+
2525
+ registerDecompressor(Compression.Deflate, () => null);
2526
+ try {
2527
+ const failed = collectImportDiagnostics((sink) => {
2528
+ expect(createScene2DFromSwf(compressed, sink)).toBeNull();
2529
+ });
2530
+ expect(failed.map((entry) => entry.kind)).toEqual(['swf.decompression-failed']);
2531
+ } finally {
2532
+ unregisterDecompressor(Compression.Deflate);
2533
+ }
2534
+ });
2535
+
2536
+ it('records nothing at all when the caller engages no collector', () => {
2537
+ const notSwf = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0, 0, 0, 0]);
2538
+ // The whole seam is opt-in: the ordinary call must not build a crumb, so this asserts the shape of
2539
+ // the default path rather than any particular diagnostic.
2540
+ expect(createScene2DFromSwf(notSwf)).toBeNull();
2541
+ expect(collectImportDiagnostics(() => {})).toEqual([]);
2542
+ });
2543
+
2544
+ it('reports a deliberately declined tag as a Skip, which is correct behaviour rather than failure', () => {
2545
+ // VideoFrame payloads are codec packets this importer does not carry. The document still imports;
2546
+ // what the crumb adds is that a caller can now tell "declined on purpose" from "silently lost".
2547
+ const file = createSwf([
2548
+ createTag(
2549
+ TAG_DEFINE_VIDEO_STREAM,
2550
+ joinBytes(uint16(4), uint16(1), uint16(16), uint16(16), new Uint8Array([0x01, 2])),
2551
+ ),
2552
+ createTag(61, joinBytes(uint16(4), uint16(0), new Uint8Array([0, 0, 0, 0]))),
2553
+ createTag(TAG_SHOW_FRAME),
2554
+ createTag(TAG_END),
2555
+ ]);
2556
+ const diagnostics = collectImportDiagnostics((sink) => {
2557
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
2558
+ });
2559
+
2560
+ const declined = diagnostics.filter((entry) => entry.kind === 'swf.video-frame-payload');
2561
+ expect(declined).toHaveLength(1);
2562
+ expect(declined[0].severity).toBe(ImportDiagnosticSeverity.Skip);
2563
+ expect(declined[0].detail).toEqual({ capability: 'swf.video.video-frame', tag: 61 });
2564
+ });
2565
+
2566
+ it('stays silent on metadata tags, whose absence costs a document nothing', () => {
2567
+ // The line this asserts is the one that keeps the report usable: a caller filtering crumbs should not
2568
+ // have to skip past authoring metadata to find the entries that mean something.
2569
+ const file = createSwf([
2570
+ createTag(69, new Uint8Array([0, 0, 0, 0])),
2571
+ createTag(77, swfString('<rdf/>')),
2572
+ createTag(TAG_SHOW_FRAME),
2573
+ createTag(TAG_END),
2574
+ ]);
2575
+ const diagnostics = collectImportDiagnostics((sink) => {
2576
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
2577
+ });
2578
+
2579
+ expect(diagnostics).toEqual([]);
2580
+ });
2581
+
2582
+ it('reports an unreadable shape body as a Recover, since the placeholder still places and sizes', () => {
2583
+ // A body the decoder cannot read costs that character's drawing and nothing else, so the honest
2584
+ // outcome is Recover: the document still imports and the character still has its authored bounds.
2585
+ const file = createSwf([
2586
+ createTag(TAG_DEFINE_SHAPE, joinBytes(uint16(1), createRectangle(0, 20, 0, 20), new Uint8Array([0xff]))),
2587
+ createTag(TAG_SHOW_FRAME),
2588
+ createTag(TAG_END),
2589
+ ]);
2590
+ const diagnostics = collectImportDiagnostics((sink) => {
2591
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
2592
+ });
2593
+
2594
+ const recovered = diagnostics.filter((entry) => entry.kind === 'swf.shape-body-unreadable');
2595
+ expect(recovered).toHaveLength(1);
2596
+ expect(recovered[0].severity).toBe(ImportDiagnosticSeverity.Recover);
2597
+ expect(recovered[0].detail).toEqual({ capability: 'swf.shape.define-shape', characterId: 1, version: 1 });
2598
+ });
2599
+
2600
+ it('reports a morph definition that does not decode, which otherwise leaves no trace at all', () => {
2601
+ // Header reads cleanly — id and both bounds — so the definition is accepted and only the morph body
2602
+ // fails. That is the case with no signal: the character is absent and the import still succeeds.
2450
2603
  const body = joinBytes(
2451
2604
  uint16(7),
2452
2605
  createRectangle(0, 200, 0, 200),
2453
2606
  createRectangle(0, 400, 0, 400),
2454
- uint32(styles.length + startEdges.length),
2455
- styles,
2456
- startEdges,
2457
- endEdges,
2458
- );
2459
- const place = (depth: number, ratio: number): Uint8Array =>
2460
- createTag(
2461
- TAG_PLACE_OBJECT_2,
2462
- joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(depth), uint16(7), uint16(ratio)),
2463
- );
2464
-
2465
- const document = createScene2DFromSwf(
2466
- createSwf([
2467
- createTag(TAG_DEFINE_MORPH_SHAPE, body),
2468
- place(1, 0),
2469
- place(2, 0x7fff),
2470
- place(3, 0xffff),
2471
- createTag(TAG_SHOW_FRAME),
2472
- createTag(TAG_END),
2473
- ]),
2607
+ uint32(0xffff),
2608
+ new Uint8Array([0]),
2474
2609
  );
2610
+ const diagnostics = collectImportDiagnostics((sink) => {
2611
+ expect(
2612
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_MORPH_SHAPE, body), createTag(TAG_END)]), sink),
2613
+ ).not.toBeNull();
2614
+ });
2475
2615
 
2476
- // The union would report 20 for all three. Each instance reports the box it is actually at, which is
2477
- // what layout and hit testing read.
2478
- const boxes = getNodeChildren(document!.root).map((node) => getNodeLocalBoundsRectangle(node).width);
2479
- expect(boxes[0]).toBeCloseTo(10);
2480
- expect(boxes[1]).toBeCloseTo(15, 1);
2481
- expect(boxes[2]).toBeCloseTo(20);
2616
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.morph-shape-undecodable');
2617
+ expect(dropped).toHaveLength(1);
2618
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2619
+ expect(dropped[0].detail).toEqual({ capability: 'swf.morph.define-morph-shape', characterId: 7 });
2482
2620
  });
2483
- });
2484
2621
 
2485
- describe('createScene2DFromSwf morph shapes', () => {
2486
- it('places a morph character and drives its progress from the placement ratio', () => {
2622
+ it('stays silent about a morph definition that decodes, so the drop entry carries information', () => {
2487
2623
  const startEdges = morphBox(200);
2488
2624
  const endEdges = morphBox(400);
2489
2625
  const styles = joinBytes(
@@ -2499,131 +2635,1342 @@ describe('createScene2DFromSwf morph shapes', () => {
2499
2635
  startEdges,
2500
2636
  endEdges,
2501
2637
  );
2638
+ const diagnostics = collectImportDiagnostics((sink) => {
2639
+ expect(
2640
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_MORPH_SHAPE, body), createTag(TAG_END)]), sink),
2641
+ ).not.toBeNull();
2642
+ });
2502
2643
 
2503
- const document = createScene2DFromSwf(
2504
- createSwf([
2505
- createTag(TAG_DEFINE_MORPH_SHAPE, body),
2506
- // Two placements of one character at different ratios: the case a single shared node cannot serve.
2507
- createTag(
2508
- TAG_PLACE_OBJECT_2,
2509
- joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(1), uint16(7), uint16(0)),
2510
- ),
2511
- createTag(
2512
- TAG_PLACE_OBJECT_2,
2513
- joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(2), uint16(7), uint16(0xffff)),
2514
- ),
2515
- createTag(TAG_SHOW_FRAME),
2516
- createTag(TAG_END),
2517
- ]),
2518
- );
2519
-
2520
- const [first, second] = getNodeChildren(document!.root) as MorphShape[];
2521
- expect(first.kind).toBe(MorphShapeKind);
2522
- expect(second.kind).toBe(MorphShapeKind);
2523
- // Distinct nodes, each sitting at its own point along the same definition.
2524
- expect(first).not.toBe(second);
2525
- expect(first.data.progress).toBe(0);
2526
- expect(second.data.progress).toBe(1);
2527
- expect(morphWidth(first)).toBeCloseTo(10);
2528
- expect(morphWidth(second)).toBeCloseTo(20);
2644
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.morph-shape-undecodable')).toEqual([]);
2529
2645
  });
2530
- });
2531
2646
 
2532
- describe('createScene2DImportFromSwf', () => {
2533
- it('keeps an advanced blend mode off the node and reports it for a BlendEffect instead', () => {
2534
- const result = createScene2DImportFromSwf(
2535
- createSwf([
2536
- createTag(
2537
- TAG_PLACE_OBJECT_3,
2538
- joinBytes(
2539
- new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_BLEND_MODE]),
2540
- uint16(3),
2541
- uint16(7),
2542
- swfString('overlaid'),
2543
- new Uint8Array([SWF_BLEND_OVERLAY]),
2544
- ),
2545
- ),
2546
- createTag(TAG_SHOW_FRAME),
2547
- createTag(TAG_END),
2548
- ]),
2647
+ it('reports a static text body that does not compose, which the deferred pass would otherwise swallow', () => {
2648
+ // A record header declaring a font and a colour, then nothing to read them from. Composition is
2649
+ // deferred to after every font is parsed, so this failure surfaces far from the tag that carried it.
2650
+ const record = new BitWriter();
2651
+ record.writeUnsigned(1, 1);
2652
+ record.writeUnsigned(0, 3);
2653
+ record.writeUnsigned(1, 1);
2654
+ record.writeUnsigned(1, 1);
2655
+ record.writeUnsigned(0, 1);
2656
+ record.writeUnsigned(0, 1);
2657
+ const text = joinBytes(
2658
+ uint16(6),
2659
+ createRectangle(0, 1024, 0, 1024),
2660
+ createMatrix(1, 0, 0, 1, 0, 0),
2661
+ new Uint8Array([4, 8]),
2662
+ record.toBytes(),
2549
2663
  );
2664
+ const diagnostics = collectImportDiagnostics((sink) => {
2665
+ expect(
2666
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_TEXT, text), createTag(TAG_END)]), sink),
2667
+ ).not.toBeNull();
2668
+ });
2550
2669
 
2551
- const overlaid = result!.document.slots[0].target;
2552
- // Assigning an advanced mode to the node would render as a silent Normal, which is the whole reason
2553
- // the two tiers are separate.
2554
- expect(overlaid.blendMode).toBe(BlendMode.Normal);
2555
- expect(result!.appearances).toEqual([
2556
- { advancedBlendMode: AdvancedBlendMode.Overlay, effects: [], frame: 1, node: overlaid },
2557
- ]);
2670
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.text-shape-uncomposable');
2671
+ expect(dropped).toHaveLength(1);
2672
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2673
+ expect(dropped[0].detail).toEqual({ capability: 'swf.text.define-text', characterId: 6 });
2558
2674
  });
2559
2675
 
2560
- it('does not invent a trailing blend mode from an unknown filter payload', () => {
2561
- const result = createScene2DImportFromSwf(
2562
- createSwf([
2563
- createTag(
2564
- TAG_PLACE_OBJECT_3,
2565
- joinBytes(
2566
- new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST | PLACE3_HAS_BLEND_MODE]),
2567
- uint16(3),
2568
- uint16(7),
2569
- swfString('unknown-filter'),
2570
- // Filter 0xfe is variable-width and unknown. Its first payload byte deliberately spells
2571
- // Overlay; the real blend byte after it spells Multiply. Neither offset is safely reachable.
2572
- new Uint8Array([1, 0xfe, SWF_BLEND_OVERLAY, SWF_BLEND_MULTIPLY]),
2573
- ),
2574
- ),
2575
- createTag(TAG_SHOW_FRAME),
2576
- createTag(TAG_END),
2577
- ]),
2676
+ it('stays silent about a static text body that composes, so the drop entry carries information', () => {
2677
+ const glyphBytes = new Uint8Array([0x30, 0x28, 0x00, 0x00, 0x40, 0x00]);
2678
+ const font = joinBytes(uint16(4), uint16(2), glyphBytes);
2679
+ const record = new BitWriter();
2680
+ record.writeUnsigned(1, 1);
2681
+ record.writeUnsigned(0, 3);
2682
+ record.writeUnsigned(1, 1);
2683
+ record.writeUnsigned(1, 1);
2684
+ record.writeUnsigned(0, 1);
2685
+ record.writeUnsigned(0, 1);
2686
+ const text = joinBytes(
2687
+ uint16(6),
2688
+ createRectangle(0, 1024, 0, 1024),
2689
+ createMatrix(1, 0, 0, 1, 0, 0),
2690
+ new Uint8Array([4, 8]),
2691
+ record.toBytes(),
2692
+ uint16(4),
2693
+ new Uint8Array([0xff, 0x00, 0x00]),
2694
+ uint16(1024),
2695
+ new Uint8Array([1]),
2696
+ packGlyphEntry(0, 600),
2697
+ new Uint8Array([0]),
2578
2698
  );
2699
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
2700
+ const diagnostics = collectImportDiagnostics((sink) => {
2701
+ document = createScene2DFromSwf(
2702
+ createSwf([
2703
+ createTag(TAG_DEFINE_FONT, font),
2704
+ createTag(TAG_DEFINE_TEXT, text),
2705
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(6))),
2706
+ createTag(TAG_SHOW_FRAME),
2707
+ createTag(TAG_END),
2708
+ ]),
2709
+ sink,
2710
+ );
2711
+ });
2579
2712
 
2580
- const node = result!.document.slots[0].target;
2581
- expect(node.blendMode).toBe(BlendMode.Normal);
2582
- expect(result!.appearances).toHaveLength(0);
2713
+ // The silence is only informative if the composition it is silent about actually happened: a run
2714
+ // that composed nothing would be silent for the wrong reason and prove nothing.
2715
+ expect((getNodeChildren(document!.root)[0] as Shape).kind).toBe(ShapeKind);
2716
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.text-shape-uncomposable')).toEqual([]);
2583
2717
  });
2584
2718
 
2585
- it('reports a placement filter list as effect descriptors and attaches nothing', () => {
2586
- const result = createScene2DImportFromSwf(
2587
- createSwf([
2588
- createTag(
2589
- TAG_PLACE_OBJECT_3,
2590
- joinBytes(
2591
- new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST]),
2592
- uint16(3),
2593
- uint16(7),
2594
- swfString('blurred'),
2595
- new Uint8Array([1, 1]),
2596
- uint32(4 * FIXED_16_ONE),
2597
- uint32(2 * FIXED_16_ONE),
2598
- new Uint8Array([0]),
2599
- ),
2600
- ),
2601
- createTag(TAG_SHOW_FRAME),
2602
- createTag(TAG_END),
2603
- ]),
2604
- );
2719
+ it('reports an edit text body that does not parse, which otherwise loses the field with no signal', () => {
2720
+ // HasFont is set and the font id and height that must follow it are not there.
2721
+ const field = joinBytes(uint16(12), createRectangle(0, 4000, 0, 800), new Uint8Array([0x80 | 0x01, 0x00]));
2722
+ const diagnostics = collectImportDiagnostics((sink) => {
2723
+ expect(
2724
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_EDIT_TEXT, field), createTag(TAG_END)]), sink),
2725
+ ).not.toBeNull();
2726
+ });
2605
2727
 
2606
- const blurred = result!.document.slots[0].target;
2607
- expect(result!.appearances).toHaveLength(1);
2608
- expect(result!.appearances[0]).toMatchObject({ advancedBlendMode: null, frame: 1, node: blurred });
2609
- expect(result!.appearances[0].effects[0]).toMatchObject({ blurX: 4, blurY: 2, kind: 'BlurEffect' });
2728
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.edit-text-unparseable');
2729
+ expect(dropped).toHaveLength(1);
2730
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2731
+ expect(dropped[0].detail).toEqual({ capability: 'swf.text.define-edit-text', characterId: 12 });
2610
2732
  });
2611
2733
 
2612
- it('joins a colour-matrix filter onto the node adjustments, since a pointwise remap folds into the draw', () => {
2613
- const cells: Uint8Array[] = [];
2614
- for (let index = 0; index < 20; index++) cells.push(float32(index % 6 === 0 ? 1 : 0));
2615
- const result = createScene2DImportFromSwf(
2616
- createSwf([
2617
- createTag(
2618
- TAG_PLACE_OBJECT_3,
2619
- joinBytes(
2620
- new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST]),
2621
- uint16(3),
2622
- uint16(7),
2623
- swfString('remapped'),
2624
- new Uint8Array([1, 6]),
2625
- ...cells,
2626
- ),
2734
+ it('stays silent about an edit text body that parses, so the drop entry carries information', () => {
2735
+ const field = joinBytes(
2736
+ uint16(12),
2737
+ createRectangle(0, 4000, 0, 800),
2738
+ new Uint8Array([0x80 | 0x40 | 0x20 | 0x04 | 0x01, 0x20 | 0x08]),
2739
+ uint16(7),
2740
+ uint16(240),
2741
+ new Uint8Array([0x11, 0x22, 0x33, 0xff]),
2742
+ new Uint8Array([2]),
2743
+ uint16(20),
2744
+ uint16(40),
2745
+ uint16(60),
2746
+ uint16(80),
2747
+ swfString('varName'),
2748
+ swfString('Hello'),
2749
+ );
2750
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
2751
+ const diagnostics = collectImportDiagnostics((sink) => {
2752
+ document = createScene2DFromSwf(
2753
+ createSwf([
2754
+ createTag(TAG_DEFINE_EDIT_TEXT, field),
2755
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(12))),
2756
+ createTag(TAG_SHOW_FRAME),
2757
+ createTag(TAG_END),
2758
+ ]),
2759
+ sink,
2760
+ );
2761
+ });
2762
+
2763
+ // Non-vacuous: a run that parsed no edit text would be silent for the wrong reason.
2764
+ expect(getNodeChildren(document!.root)).toHaveLength(1);
2765
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.edit-text-unparseable')).toEqual([]);
2766
+ });
2767
+
2768
+ it('reports an ABC blob that yields no frame scripts, naming which of the two DoABC forms it was', () => {
2769
+ // A DoABC payload whose ABC body is not readable. The two tag forms are separate capabilities, so
2770
+ // the entry has to name which one it was or it cannot be joined to either.
2771
+ const named = joinBytes(uint32(0), swfString('frame'), new Uint8Array([0xff, 0xff, 0xff, 0xff]));
2772
+ const diagnostics = collectImportDiagnostics((sink) => {
2773
+ expect(createScene2DFromSwf(createSwf([createTag(TAG_DO_ABC, named), createTag(TAG_END)]), sink)).not.toBeNull();
2774
+ });
2775
+
2776
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.abc-frame-scripts-unreadable');
2777
+ expect(dropped).toHaveLength(1);
2778
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2779
+ expect(dropped[0].detail).toEqual({ capability: 'swf.script.do-abc' });
2780
+ });
2781
+
2782
+ it('names the anonymous DoABC form separately, since the two are different capabilities', () => {
2783
+ const anonymous = new Uint8Array([0xff, 0xff, 0xff, 0xff]);
2784
+ const diagnostics = collectImportDiagnostics((sink) => {
2785
+ expect(
2786
+ createScene2DFromSwf(createSwf([createTag(TAG_DO_ABC_ANONYMOUS, anonymous), createTag(TAG_END)]), sink),
2787
+ ).not.toBeNull();
2788
+ });
2789
+
2790
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.abc-frame-scripts-unreadable');
2791
+ expect(dropped).toHaveLength(1);
2792
+ expect(dropped[0].detail).toEqual({ capability: 'swf.script.do-abc-anonymous' });
2793
+ });
2794
+
2795
+ it('reports a declared blend mode left unread behind a filter list that did not finish', () => {
2796
+ // Filter id 0xff has no known payload width, so the list cannot be walked to its end and the blend
2797
+ // byte behind it is unreachable. The placement keeps everything else, which is why this is silent.
2798
+ const diagnostics = collectImportDiagnostics((sink) => {
2799
+ expect(
2800
+ createScene2DFromSwf(
2801
+ createSwf([
2802
+ createTag(
2803
+ TAG_PLACE_OBJECT_3,
2804
+ joinBytes(
2805
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST | PLACE3_HAS_BLEND_MODE]),
2806
+ uint16(3),
2807
+ uint16(7),
2808
+ swfString('filtered'),
2809
+ new Uint8Array([1, 0xff]),
2810
+ new Uint8Array([SWF_BLEND_MULTIPLY]),
2811
+ ),
2812
+ ),
2813
+ createTag(TAG_SHOW_FRAME),
2814
+ createTag(TAG_END),
2815
+ ]),
2816
+ sink,
2817
+ ),
2818
+ ).not.toBeNull();
2819
+ });
2820
+
2821
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.blend-mode-behind-unread-filters');
2822
+ expect(dropped).toHaveLength(1);
2823
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2824
+ expect(dropped[0].detail).toEqual({ capability: 'swf.placement.blend-mode' });
2825
+ });
2826
+
2827
+ it('stays silent when a declared blend mode is reachable, so the drop entry carries information', () => {
2828
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
2829
+ const diagnostics = collectImportDiagnostics((sink) => {
2830
+ document = createScene2DFromSwf(
2831
+ createSwf([
2832
+ createTag(
2833
+ TAG_PLACE_OBJECT_3,
2834
+ joinBytes(
2835
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_BLEND_MODE]),
2836
+ uint16(3),
2837
+ uint16(7),
2838
+ swfString('multiplied'),
2839
+ new Uint8Array([SWF_BLEND_MULTIPLY]),
2840
+ ),
2841
+ ),
2842
+ createTag(TAG_SHOW_FRAME),
2843
+ createTag(TAG_END),
2844
+ ]),
2845
+ sink,
2846
+ );
2847
+ });
2848
+
2849
+ // Non-vacuous: the blend mode was actually read and applied.
2850
+ expect(document!.slots[0].target.blendMode).toBe(BlendMode.Multiply);
2851
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.blend-mode-behind-unread-filters')).toEqual([]);
2852
+ });
2853
+
2854
+ it('counts the children a sprite bounds union could not include, since the box survives smaller', () => {
2855
+ // Character 7 is defined and character 9 is not. The union covers 7 alone, so the sprite's authored
2856
+ // box is real and short — the diminished case a count catches and an existence check cannot.
2857
+ const diagnostics = collectImportDiagnostics((sink) => {
2858
+ expect(
2859
+ createScene2DFromSwf(
2860
+ createSwf([
2861
+ createTag(TAG_DEFINE_SHAPE, joinBytes(uint16(7), createRectangle(0, 200, 0, 100))),
2862
+ createTag(
2863
+ TAG_DEFINE_SPRITE,
2864
+ joinBytes(
2865
+ uint16(20),
2866
+ uint16(1),
2867
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(7))),
2868
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(2), uint16(9))),
2869
+ createTag(TAG_SHOW_FRAME),
2870
+ createTag(TAG_END),
2871
+ ),
2872
+ ),
2873
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(20))),
2874
+ createTag(TAG_SHOW_FRAME),
2875
+ createTag(TAG_END),
2876
+ ]),
2877
+ sink,
2878
+ ),
2879
+ ).not.toBeNull();
2880
+ });
2881
+
2882
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.sprite-bounds-short');
2883
+ expect(dropped).toHaveLength(1);
2884
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2885
+ expect(dropped[0].detail).toEqual({
2886
+ capability: 'swf.timeline.define-sprite',
2887
+ characterId: 20,
2888
+ missingChildren: 1,
2889
+ });
2890
+ });
2891
+
2892
+ it('stays silent when every child of a sprite contributes its bounds', () => {
2893
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
2894
+ const diagnostics = collectImportDiagnostics((sink) => {
2895
+ document = createScene2DFromSwf(
2896
+ createSwf([
2897
+ createTag(TAG_DEFINE_SHAPE, joinBytes(uint16(7), createRectangle(0, 200, 0, 100))),
2898
+ createTag(
2899
+ TAG_DEFINE_SPRITE,
2900
+ joinBytes(
2901
+ uint16(20),
2902
+ uint16(1),
2903
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(7))),
2904
+ createTag(TAG_SHOW_FRAME),
2905
+ createTag(TAG_END),
2906
+ ),
2907
+ ),
2908
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(20))),
2909
+ createTag(TAG_SHOW_FRAME),
2910
+ createTag(TAG_END),
2911
+ ]),
2912
+ sink,
2913
+ );
2914
+ });
2915
+
2916
+ // Non-vacuous: the sprite exists and its union was actually computed.
2917
+ expect(getNodeChildren(document!.root)).toHaveLength(1);
2918
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.sprite-bounds-short')).toEqual([]);
2919
+ });
2920
+
2921
+ it('reports an advanced blend that had no node to carry it, which the appearance report alone holds', () => {
2922
+ // An unnamed placement of a character that was never defined earns no node, so the appearance report
2923
+ // — the only carrier for advanced blends and filter lists — has nothing to attach to.
2924
+ const diagnostics = collectImportDiagnostics((sink) => {
2925
+ expect(
2926
+ createScene2DFromSwf(
2927
+ createSwf([
2928
+ createTag(
2929
+ TAG_PLACE_OBJECT_3,
2930
+ joinBytes(
2931
+ new Uint8Array([PLACE_HAS_CHARACTER, PLACE3_HAS_BLEND_MODE]),
2932
+ uint16(1),
2933
+ uint16(7),
2934
+ new Uint8Array([SWF_BLEND_OVERLAY]),
2935
+ ),
2936
+ ),
2937
+ createTag(TAG_SHOW_FRAME),
2938
+ createTag(TAG_END),
2939
+ ]),
2940
+ sink,
2941
+ ),
2942
+ ).not.toBeNull();
2943
+ });
2944
+
2945
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.appearance-without-node');
2946
+ expect(dropped).toHaveLength(1);
2947
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2948
+ expect(dropped[0].detail).toEqual({ capability: 'swf.placement.blend-mode', frame: 1 });
2949
+ });
2950
+
2951
+ it('stays silent when an advanced blend has a node to carry it, so the drop entry carries information', () => {
2952
+ const diagnostics = collectImportDiagnostics((sink) => {
2953
+ const result = createScene2DImportFromSwf(
2954
+ createSwf([
2955
+ createTag(
2956
+ TAG_PLACE_OBJECT_3,
2957
+ joinBytes(
2958
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_BLEND_MODE]),
2959
+ uint16(3),
2960
+ uint16(7),
2961
+ swfString('overlaid'),
2962
+ new Uint8Array([SWF_BLEND_OVERLAY]),
2963
+ ),
2964
+ ),
2965
+ createTag(TAG_SHOW_FRAME),
2966
+ createTag(TAG_END),
2967
+ ]),
2968
+ sink,
2969
+ );
2970
+ // Non-vacuous: the appearance really was recorded against a node.
2971
+ expect(result!.appearances).toHaveLength(1);
2972
+ });
2973
+
2974
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.appearance-without-node')).toEqual([]);
2975
+ });
2976
+
2977
+ it('reports a reused font character id, the one case where the document imports and is simply wrong', () => {
2978
+ // Two DefineFont tags claim id 4. The second replaces the first; the document imports, the font
2979
+ // exists, and it is the wrong font — the substituted case no existence check and no count can see.
2980
+ const glyphBytes = new Uint8Array([0x30, 0x28, 0x00, 0x00, 0x40, 0x00]);
2981
+ const font = joinBytes(uint16(4), uint16(2), glyphBytes);
2982
+ const diagnostics = collectImportDiagnostics((sink) => {
2983
+ expect(
2984
+ createScene2DFromSwf(
2985
+ createSwf([createTag(TAG_DEFINE_FONT, font), createTag(TAG_DEFINE_FONT, font), createTag(TAG_END)]),
2986
+ sink,
2987
+ ),
2988
+ ).not.toBeNull();
2989
+ });
2990
+
2991
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.font-character-id-reused');
2992
+ expect(dropped).toHaveLength(1);
2993
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
2994
+ expect(dropped[0].detail).toEqual({ capability: 'swf.font.define-font', characterId: 4 });
2995
+ });
2996
+
2997
+ it('stays silent about a font whose character id is used once, so the entry carries information', () => {
2998
+ const glyphBytes = new Uint8Array([0x30, 0x28, 0x00, 0x00, 0x40, 0x00]);
2999
+ const diagnostics = collectImportDiagnostics((sink) => {
3000
+ expect(
3001
+ createScene2DFromSwf(
3002
+ createSwf([
3003
+ createTag(TAG_DEFINE_FONT, joinBytes(uint16(4), uint16(2), glyphBytes)),
3004
+ createTag(TAG_DEFINE_FONT, joinBytes(uint16(5), uint16(2), glyphBytes)),
3005
+ createTag(TAG_END),
3006
+ ]),
3007
+ sink,
3008
+ ),
3009
+ ).not.toBeNull();
3010
+ });
3011
+
3012
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.font-character-id-reused')).toEqual([]);
3013
+ });
3014
+
3015
+ it('reports an edit text whose font id resolves to no name, leaving the field sized but unfamilied', () => {
3016
+ // The field keeps its box, colour and size and simply has no font family. Asserting the box survives
3017
+ // is what makes this the diminished case rather than a missing field.
3018
+ const field = joinBytes(
3019
+ uint16(12),
3020
+ createRectangle(0, 4000, 0, 800),
3021
+ new Uint8Array([0x80 | 0x40 | 0x20 | 0x04 | 0x01, 0x20 | 0x08]),
3022
+ uint16(7),
3023
+ uint16(240),
3024
+ new Uint8Array([0x11, 0x22, 0x33, 0xff]),
3025
+ new Uint8Array([2]),
3026
+ uint16(20),
3027
+ uint16(40),
3028
+ uint16(60),
3029
+ uint16(80),
3030
+ swfString('varName'),
3031
+ swfString('Hello'),
3032
+ );
3033
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
3034
+ const diagnostics = collectImportDiagnostics((sink) => {
3035
+ document = createScene2DFromSwf(
3036
+ createSwf([
3037
+ createTag(TAG_DEFINE_EDIT_TEXT, field),
3038
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(12))),
3039
+ createTag(TAG_SHOW_FRAME),
3040
+ createTag(TAG_END),
3041
+ ]),
3042
+ sink,
3043
+ );
3044
+ });
3045
+
3046
+ expect(getNodeChildren(document!.root)).toHaveLength(1);
3047
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.edit-text-font-name-unresolved');
3048
+ expect(dropped).toHaveLength(1);
3049
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3050
+ expect(dropped[0].detail).toEqual({ capability: 'swf.text.define-edit-text', fontId: 7 });
3051
+ });
3052
+
3053
+ it('reports a declined frame script through the full import path, not only the reader in isolation', () => {
3054
+ // A fire proof at the reader shows the wire fires; it does not show production reaches the branch.
3055
+ // Family 8 was reachable only by a route other than the one predicted, so this asks the same question
3056
+ // of the frame-script wire by carrying a real DoABC payload through createScene2DFromSwf.
3057
+ const abc = buildFrameScriptAbc(1);
3058
+ const payload = joinBytes(uint32(0), swfString('frame'), abc);
3059
+ const diagnostics = collectImportDiagnostics((sink) => {
3060
+ expect(
3061
+ createScene2DFromSwf(createSwf([createTag(TAG_DO_ABC, payload), createTag(TAG_END)]), sink),
3062
+ ).not.toBeNull();
3063
+ });
3064
+
3065
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.abc-frame-script-declined');
3066
+ expect(dropped).toHaveLength(1);
3067
+ expect(dropped[0].detail).toEqual({ frame: 1, reason: 'commands-declined' });
3068
+ });
3069
+
3070
+ it('reports an undecodable morph for DefineMorphShape2, not only the generation the wire was written on', () => {
3071
+ // A wire carrying a version ternary is only proven for the branch a test exercises. This is the same
3072
+ // check already applied to the shape generations, applied to the morph wire.
3073
+ const body = joinBytes(
3074
+ uint16(7),
3075
+ createRectangle(0, 200, 0, 200),
3076
+ createRectangle(0, 400, 0, 400),
3077
+ createRectangle(0, 200, 0, 200),
3078
+ createRectangle(0, 400, 0, 400),
3079
+ new Uint8Array([0]),
3080
+ uint32(0xffff),
3081
+ new Uint8Array([0]),
3082
+ );
3083
+ const diagnostics = collectImportDiagnostics((sink) => {
3084
+ expect(
3085
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_MORPH_SHAPE_2, body), createTag(TAG_END)]), sink),
3086
+ ).not.toBeNull();
3087
+ });
3088
+
3089
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.morph-shape-undecodable');
3090
+ expect(dropped).toHaveLength(1);
3091
+ expect(dropped[0].detail).toEqual({ capability: 'swf.morph.define-morph-shape-2', characterId: 7 });
3092
+ });
3093
+
3094
+ it('reports an uncomposable body for DefineText2, not only the generation the wire was written on', () => {
3095
+ const record = new BitWriter();
3096
+ record.writeUnsigned(1, 1);
3097
+ record.writeUnsigned(0, 3);
3098
+ record.writeUnsigned(1, 1);
3099
+ record.writeUnsigned(1, 1);
3100
+ record.writeUnsigned(0, 1);
3101
+ record.writeUnsigned(0, 1);
3102
+ const text = joinBytes(
3103
+ uint16(6),
3104
+ createRectangle(0, 1024, 0, 1024),
3105
+ createMatrix(1, 0, 0, 1, 0, 0),
3106
+ new Uint8Array([4, 8]),
3107
+ record.toBytes(),
3108
+ );
3109
+ const diagnostics = collectImportDiagnostics((sink) => {
3110
+ expect(
3111
+ createScene2DFromSwf(createSwf([createTag(TAG_DEFINE_TEXT_2, text), createTag(TAG_END)]), sink),
3112
+ ).not.toBeNull();
3113
+ });
3114
+
3115
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.text-shape-uncomposable');
3116
+ expect(dropped).toHaveLength(1);
3117
+ expect(dropped[0].detail).toEqual({ capability: 'swf.text.define-text-2', characterId: 6 });
3118
+ });
3119
+
3120
+ it('reports a reused character id for DefineFont2, not only the generation the wire was written on', () => {
3121
+ // A glyph-less DefineFont2 still parses and still claims the id, which is all the duplicate needs.
3122
+ const font2 = joinBytes(uint16(4), new Uint8Array([0, 0, 0]), uint16(0), uint16(0));
3123
+ const diagnostics = collectImportDiagnostics((sink) => {
3124
+ expect(
3125
+ createScene2DFromSwf(
3126
+ createSwf([createTag(TAG_DEFINE_FONT_2, font2), createTag(TAG_DEFINE_FONT_2, font2), createTag(TAG_END)]),
3127
+ sink,
3128
+ ),
3129
+ ).not.toBeNull();
3130
+ });
3131
+
3132
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.font-character-id-reused');
3133
+ expect(dropped).toHaveLength(1);
3134
+ expect(dropped[0].detail).toEqual({ capability: 'swf.font.define-font-2', characterId: 4 });
3135
+ });
3136
+
3137
+ it('stays silent when an edit text font id does resolve, so the unresolved entry carries information', () => {
3138
+ // Recorded earlier as an absent silence proof: it needs a DefineFont2 that parses, and none existed.
3139
+ // The version-routing work produced one — a glyph-less DefineFont2 parses and carries a name — so the
3140
+ // hole closes. Worth noting the fixture came from unrelated work rather than from trying harder.
3141
+ const font2 = joinBytes(uint16(7), new Uint8Array([0, 0, 5]), swfBytes('Arial'), uint16(0), uint16(0));
3142
+ const field = joinBytes(
3143
+ uint16(12),
3144
+ createRectangle(0, 4000, 0, 800),
3145
+ new Uint8Array([0x80 | 0x40 | 0x20 | 0x04 | 0x01, 0x20 | 0x08]),
3146
+ uint16(7),
3147
+ uint16(240),
3148
+ new Uint8Array([0x11, 0x22, 0x33, 0xff]),
3149
+ new Uint8Array([2]),
3150
+ uint16(20),
3151
+ uint16(40),
3152
+ uint16(60),
3153
+ uint16(80),
3154
+ swfString('varName'),
3155
+ swfString('Hello'),
3156
+ );
3157
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
3158
+ const diagnostics = collectImportDiagnostics((sink) => {
3159
+ document = createScene2DFromSwf(
3160
+ createSwf([
3161
+ createTag(TAG_DEFINE_FONT_2, font2),
3162
+ createTag(TAG_DEFINE_EDIT_TEXT, field),
3163
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(12))),
3164
+ createTag(TAG_SHOW_FRAME),
3165
+ createTag(TAG_END),
3166
+ ]),
3167
+ sink,
3168
+ );
3169
+ });
3170
+
3171
+ // Non-vacuous: the field exists, so the resolver really ran against a font id that resolved.
3172
+ expect(getNodeChildren(document!.root)).toHaveLength(1);
3173
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.edit-text-font-name-unresolved')).toEqual([]);
3174
+ });
3175
+
3176
+ it('stays silent about an anonymous DoABC it does obey, so the drop entry carries information', () => {
3177
+ // An anonymous DoABC carries the raw ABC with no flags-and-name header, so the shared builder is
3178
+ // enough on its own. Recorded earlier as an absent silence proof; the helper closed it.
3179
+ const diagnostics = collectImportDiagnostics((sink) => {
3180
+ expect(
3181
+ createScene2DFromSwf(
3182
+ createSwf([createTag(TAG_DO_ABC_ANONYMOUS, buildFrameScriptAbc()), createTag(TAG_END)]),
3183
+ sink,
3184
+ ),
3185
+ ).not.toBeNull();
3186
+ });
3187
+
3188
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.abc-frame-scripts-unreadable')).toEqual([]);
3189
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.abc-frame-script-declined')).toEqual([]);
3190
+ });
3191
+
3192
+ it('stays silent about a DefineText2 body that composes, so the drop entry carries information', () => {
3193
+ const glyphBytes = new Uint8Array([0x30, 0x28, 0x00, 0x00, 0x40, 0x00]);
3194
+ const font = joinBytes(uint16(4), uint16(2), glyphBytes);
3195
+ const record = new BitWriter();
3196
+ record.writeUnsigned(1, 1);
3197
+ record.writeUnsigned(0, 3);
3198
+ record.writeUnsigned(1, 1);
3199
+ record.writeUnsigned(1, 1);
3200
+ record.writeUnsigned(0, 1);
3201
+ record.writeUnsigned(0, 1);
3202
+ const text = joinBytes(
3203
+ uint16(6),
3204
+ createRectangle(0, 1024, 0, 1024),
3205
+ createMatrix(1, 0, 0, 1, 0, 0),
3206
+ new Uint8Array([4, 8]),
3207
+ record.toBytes(),
3208
+ uint16(4),
3209
+ // DefineText2 records carry RGBA where DefineText carries RGB — the version difference that makes
3210
+ // this a separate silence proof rather than the same one relabelled.
3211
+ new Uint8Array([0xff, 0x00, 0x00, 0xff]),
3212
+ uint16(1024),
3213
+ new Uint8Array([1]),
3214
+ packGlyphEntry(0, 600),
3215
+ new Uint8Array([0]),
3216
+ );
3217
+ let document: ReturnType<typeof createScene2DFromSwf> = null;
3218
+ const diagnostics = collectImportDiagnostics((sink) => {
3219
+ document = createScene2DFromSwf(
3220
+ createSwf([
3221
+ createTag(TAG_DEFINE_FONT, font),
3222
+ createTag(TAG_DEFINE_TEXT_2, text),
3223
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(6))),
3224
+ createTag(TAG_SHOW_FRAME),
3225
+ createTag(TAG_END),
3226
+ ]),
3227
+ sink,
3228
+ );
3229
+ });
3230
+
3231
+ // Non-vacuous: a run that composed nothing would be silent for the wrong reason.
3232
+ expect((getNodeChildren(document!.root)[0] as Shape).kind).toBe(ShapeKind);
3233
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.text-shape-uncomposable')).toEqual([]);
3234
+ });
3235
+
3236
+ it('reports each whole-document header rejection with its own cause, not one shared null', () => {
3237
+ // Every case below loses the entire document. Before these wires the caller got one null and could
3238
+ // not tell a truncated header from an unregistered decompressor, which IS reported upstream.
3239
+ const kindFor = (file: Uint8Array): readonly string[] =>
3240
+ collectImportDiagnostics((sink) => {
3241
+ expect(createScene2DFromSwf(file, sink)).toBeNull();
3242
+ }).map((entry) => entry.kind);
3243
+
3244
+ // Version 0 is invalid however well-formed the rest is.
3245
+ const zeroVersion = joinBytes(new Uint8Array([0x46, 0x57, 0x53, 0]), uint32(64), createRectangle(0, 10, 0, 10));
3246
+ expect(kindFor(zeroVersion)).toContain('swf.header-fields-invalid');
3247
+
3248
+ // A declared length that passes the minimum, and a stage RECT claiming 31 bits per field with far
3249
+ // too few bytes behind it inside that length.
3250
+ const noStage = joinBytes(new Uint8Array([0x46, 0x57, 0x53, 9]), uint32(12), new Uint8Array([0xf8, 0, 0, 0]));
3251
+ expect(kindFor(noStage)).toContain('swf.stage-bounds-unreadable');
3252
+
3253
+ // A stage RECT that reads, then no frame rate or frame count behind it.
3254
+ const stage = createRectangle(0, 2000, 0, 1000);
3255
+ const noFrameRate = joinBytes(new Uint8Array([0x46, 0x57, 0x53, 9]), uint32(8 + stage.length), stage);
3256
+ expect(kindFor(noFrameRate)).toContain('swf.header-truncated');
3257
+ });
3258
+
3259
+ it('reports a discarded JPEG alpha stream as a Drop, since the bytes are present and go unread', () => {
3260
+ const jpeg3 = createJpegHeader(23, 17);
3261
+ const file = createSwf([
3262
+ createTag(TAG_DEFINE_BITS_JPEG_3, joinBytes(uint16(16), uint32(jpeg3.length), jpeg3, new Uint8Array([1, 2, 3]))),
3263
+ createTag(TAG_SHOW_FRAME),
3264
+ createTag(TAG_END),
3265
+ ]);
3266
+ const diagnostics = collectImportDiagnostics((sink) => {
3267
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3268
+ });
3269
+
3270
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.jpeg-alpha-stream');
3271
+ expect(dropped).toHaveLength(1);
3272
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3273
+ expect(dropped[0].detail).toEqual({
3274
+ capability: 'swf.bitmap.define-bits-jpeg-3',
3275
+ characterId: 16,
3276
+ discardedBytes: 3,
3277
+ });
3278
+ });
3279
+
3280
+ it('reports a legacy split JPEG with no tables in the file as a Drop', () => {
3281
+ const file = createSwf([
3282
+ createTag(TAG_DEFINE_BITS, joinBytes(uint16(9), new Uint8Array([0xff, 0xd8, 0xff, 0xd9]))),
3283
+ createTag(TAG_SHOW_FRAME),
3284
+ createTag(TAG_END),
3285
+ ]);
3286
+ const diagnostics = collectImportDiagnostics((sink) => {
3287
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3288
+ });
3289
+
3290
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.jpeg-tables-missing');
3291
+ expect(dropped).toHaveLength(1);
3292
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3293
+ expect(dropped[0].detail).toEqual({ capability: 'swf.bitmap.define-bits-jpeg-tables', characterId: 9 });
3294
+ });
3295
+
3296
+ it('reports scene names as a Skip, since labels import and the named range has no subject', () => {
3297
+ const file = createSwf([
3298
+ createTag(
3299
+ TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA,
3300
+ joinBytes(encodedUint32(1), encodedUint32(0), swfString('Scene 1'), encodedUint32(0)),
3301
+ ),
3302
+ createTag(TAG_SHOW_FRAME),
3303
+ createTag(TAG_END),
3304
+ ]);
3305
+ const diagnostics = collectImportDiagnostics((sink) => {
3306
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3307
+ });
3308
+
3309
+ const skipped = diagnostics.filter((entry) => entry.kind === 'swf.scene-names');
3310
+ expect(skipped).toHaveLength(1);
3311
+ expect(skipped[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3312
+ expect(skipped[0].detail).toEqual({
3313
+ capability: 'swf.timeline.define-scene-and-frame-label-data',
3314
+ sceneCount: 1,
3315
+ });
3316
+ });
3317
+
3318
+ it('reports a frame script declined for carrying more than playback commands', () => {
3319
+ // 0x96 push, then a non-playback action: the block is declined WHOLE, because honouring the legible
3320
+ // half would misrepresent the frame. The crumb is what tells a caller the frame had a script at all.
3321
+ const file = createSwf([
3322
+ createTag(TAG_DO_ACTION, new Uint8Array([0x96, 0x02, 0x00, 0x08, 0x00, 0x3d, 0x00])),
3323
+ createTag(TAG_SHOW_FRAME),
3324
+ createTag(TAG_END),
3325
+ ]);
3326
+ const diagnostics = collectImportDiagnostics((sink) => {
3327
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3328
+ });
3329
+
3330
+ const skipped = diagnostics.filter((entry) => entry.kind === 'swf.frame-script-declined');
3331
+ expect(skipped).toHaveLength(1);
3332
+ expect(skipped[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3333
+ expect(skipped[0].detail).toEqual({ capability: 'swf.script.do-action', frame: 1 });
3334
+ });
3335
+
3336
+ it('reports an init action declined the same way DoAction is, which it silently did not', () => {
3337
+ // The identical decline four lines above the DoAction case reported nothing. Found by the silent-drop
3338
+ // shape sweep, not by reading — three passes over this file read both branches and saw no asymmetry.
3339
+ const file = createSwf([
3340
+ createTag(TAG_DO_INIT_ACTION, joinBytes(uint16(20), new Uint8Array([0x96, 0x02, 0x00, 0x08, 0x00, 0x3d, 0x00]))),
3341
+ createTag(TAG_SHOW_FRAME),
3342
+ createTag(TAG_END),
3343
+ ]);
3344
+ const diagnostics = collectImportDiagnostics((sink) => {
3345
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3346
+ });
3347
+
3348
+ const skipped = diagnostics.filter((entry) => entry.kind === 'swf.frame-script-declined');
3349
+ expect(skipped).toHaveLength(1);
3350
+ expect(skipped[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3351
+ expect(skipped[0].detail).toEqual({ capability: 'swf.script.do-init-action', characterId: 20 });
3352
+ });
3353
+
3354
+ it('stays silent about an init action it does obey, so the skip entry carries information', () => {
3355
+ const file = createSwf([
3356
+ createTag(TAG_DO_INIT_ACTION, joinBytes(uint16(20), new Uint8Array([0x07, 0x00]))),
3357
+ createTag(TAG_SHOW_FRAME),
3358
+ createTag(TAG_END),
3359
+ ]);
3360
+ const diagnostics = collectImportDiagnostics((sink) => {
3361
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3362
+ });
3363
+
3364
+ expect(diagnostics.filter((entry) => entry.kind === 'swf.frame-script-declined')).toEqual([]);
3365
+ });
3366
+
3367
+ it('reports nested masks collapsing, since the outer one is not applied at all', () => {
3368
+ // Two clip depths covering one instance: Flight carries one clip per node, so the outer mask is
3369
+ // simply not applied and the instance shows more than the file said. The crumb is the only signal.
3370
+ const file = createSwf([
3371
+ createTag(
3372
+ TAG_PLACE_OBJECT_2,
3373
+ joinBytes(new Uint8Array([PLACE_HAS_CLIP_DEPTH | PLACE_HAS_CHARACTER]), uint16(1), uint16(1), uint16(9)),
3374
+ ),
3375
+ createTag(
3376
+ TAG_PLACE_OBJECT_2,
3377
+ joinBytes(new Uint8Array([PLACE_HAS_CLIP_DEPTH | PLACE_HAS_CHARACTER]), uint16(2), uint16(2), uint16(9)),
3378
+ ),
3379
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(3), uint16(3))),
3380
+ createTag(TAG_SHOW_FRAME),
3381
+ createTag(TAG_END),
3382
+ ]);
3383
+ const diagnostics = collectImportDiagnostics((sink) => {
3384
+ createScene2DFromSwf(file, sink);
3385
+ });
3386
+
3387
+ const collapsed = diagnostics.filter((entry) => entry.kind === 'swf.nested-mask-collapsed');
3388
+ expect(collapsed.length).toBeGreaterThan(0);
3389
+ expect(collapsed[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3390
+ expect(collapsed[0].detail?.capability).toBe('swf.placement.clip-depth');
3391
+ expect(collapsed[0].detail?.covering).toBe(2);
3392
+
3393
+ // The masks name characters with no decoded geometry, so the same fixture exercises clip-depth's
3394
+ // other loss path. Both paths must be proven for the capability to count as instrumented.
3395
+ const unmasked = diagnostics.filter((entry) => entry.kind === 'swf.mask-without-geometry');
3396
+ expect(unmasked.length).toBeGreaterThan(0);
3397
+ expect(unmasked[0].severity).toBe(ImportDiagnosticSeverity.Recover);
3398
+ expect(unmasked[0].detail?.capability).toBe('swf.placement.clip-depth');
3399
+ });
3400
+
3401
+ it('reports a button interaction state other than up, which a still scene does not hold', () => {
3402
+ const file = createSwf([
3403
+ createTag(
3404
+ TAG_DEFINE_BUTTON_2,
3405
+ joinBytes(
3406
+ uint16(12),
3407
+ new Uint8Array([0, 0, 0]),
3408
+ new Uint8Array([0x02]),
3409
+ uint16(7),
3410
+ uint16(1),
3411
+ createRectangle(0, 0, 0, 0),
3412
+ new Uint8Array([0, 0]),
3413
+ ),
3414
+ ),
3415
+ createTag(TAG_SHOW_FRAME),
3416
+ createTag(TAG_END),
3417
+ ]);
3418
+ const diagnostics = collectImportDiagnostics((sink) => {
3419
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3420
+ });
3421
+
3422
+ const skipped = diagnostics.filter((entry) => entry.kind === 'swf.button-interaction-state');
3423
+ expect(skipped.length).toBeGreaterThan(0);
3424
+ expect(skipped[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3425
+ // The PAYLOAD, not just the trigger: a crumb that fires correctly while recording something false
3426
+ // is a clean positive that is not one. The fixture's non-up record names character 7 with state
3427
+ // flags 0x02, and the crumb must say so rather than merely existing.
3428
+ expect(skipped[0].detail).toEqual({ characterId: 7, flags: 0x02 });
3429
+ });
3430
+
3431
+ it('reports a legacy split JPEG whose halves will not splice into a readable image', () => {
3432
+ const file = createSwf([
3433
+ createTag(TAG_JPEG_TABLES, new Uint8Array([0xff, 0xd8, 0xff, 0xd9])),
3434
+ createTag(TAG_DEFINE_BITS, joinBytes(uint16(9), new Uint8Array([0x01, 0x02, 0x03, 0x04]))),
3435
+ createTag(TAG_SHOW_FRAME),
3436
+ createTag(TAG_END),
3437
+ ]);
3438
+ const diagnostics = collectImportDiagnostics((sink) => {
3439
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3440
+ });
3441
+
3442
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.jpeg-tables-unsplittable');
3443
+ expect(dropped).toHaveLength(1);
3444
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3445
+ expect(dropped[0].detail).toEqual({ capability: 'swf.bitmap.define-bits-jpeg-tables', characterId: 9 });
3446
+ });
3447
+
3448
+ it('reports a font whose glyph table does not decode, which costs the whole font not one glyph', () => {
3449
+ // Found by auditing my own trustworthy-silence claim rather than by a failing test: a font that
3450
+ // vanishes entirely and a font that imports cleanly both produced no crumb before this.
3451
+ const file = createSwf([
3452
+ createTag(TAG_DEFINE_FONT_2, joinBytes(uint16(4), new Uint8Array([0xff, 0xff, 0xff, 0xff]))),
3453
+ createTag(TAG_SHOW_FRAME),
3454
+ createTag(TAG_END),
3455
+ ]);
3456
+ const diagnostics = collectImportDiagnostics((sink) => {
3457
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3458
+ });
3459
+
3460
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.font-glyph-table');
3461
+ expect(dropped).toHaveLength(1);
3462
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3463
+ expect(dropped[0].detail).toEqual({ capability: 'swf.font.define-font-2', characterId: 4 });
3464
+ });
3465
+
3466
+ it('reports a non-MP3 sound stream, whose blocks do not concatenate', () => {
3467
+ // Stream format 1 (ADPCM) in the high nibble of the stream flags, with a non-zero samples-per-frame
3468
+ // so the header actually starts a stream. Only MP3 blocks concatenate, so the rest are a real loss.
3469
+ const file = createSwf([
3470
+ createTag(18, joinBytes(new Uint8Array([0, 1 << 4]), uint16(1152))),
3471
+ createTag(19, new Uint8Array([1, 2, 3, 4])),
3472
+ createTag(TAG_SHOW_FRAME),
3473
+ createTag(TAG_END),
3474
+ ]);
3475
+ const diagnostics = collectImportDiagnostics((sink) => {
3476
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3477
+ });
3478
+
3479
+ const skipped = diagnostics.filter((entry) => entry.kind === 'swf.stream-sound-format');
3480
+ expect(skipped).toHaveLength(1);
3481
+ expect(skipped[0].severity).toBe(ImportDiagnosticSeverity.Skip);
3482
+ expect(skipped[0].detail).toEqual({ capability: 'swf.axis.sound-format-non-mp3', format: 1 });
3483
+ });
3484
+
3485
+ it('reports one glyph whose outline does not decode, which costs that glyph and not the font', () => {
3486
+ // Two glyphs: the first is a real square, the second is a body the shape reader cannot read. The
3487
+ // font still imports — that is the point of the Drop rather than a whole-font failure.
3488
+ const glyph = new ShapeWriter();
3489
+ glyph.writeStyleBits(1, 0);
3490
+ glyph.writeStyleChange({ fill1: 1, moveToX: 0, moveToY: 0 });
3491
+ glyph.writeStraightEdge(256, 0);
3492
+ glyph.writeStraightEdge(0, 256);
3493
+ glyph.writeStraightEdge(-256, 0);
3494
+ glyph.writeStraightEdge(0, -256);
3495
+ glyph.writeEndShape();
3496
+ const good = glyph.toBytes();
3497
+ const bad = new Uint8Array([0xff]);
3498
+ const offsets = joinBytes(uint16(6), uint16(6 + good.length), uint16(6 + good.length + bad.length));
3499
+ const file = createSwf([
3500
+ createTag(
3501
+ TAG_DEFINE_FONT_2,
3502
+ joinBytes(uint16(4), new Uint8Array([0, 0, 0]), uint16(2), offsets, good, bad, new Uint8Array([0x41, 0x42])),
3503
+ ),
3504
+ createTag(TAG_SHOW_FRAME),
3505
+ createTag(TAG_END),
3506
+ ]);
3507
+ const diagnostics = collectImportDiagnostics((sink) => {
3508
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3509
+ });
3510
+
3511
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.font-glyph-outline');
3512
+ expect(dropped).toHaveLength(1);
3513
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3514
+ expect(dropped[0].detail?.capability).toBe('swf.font.define-font-2');
3515
+ expect(dropped[0].detail?.lostGlyphs).toBe(1);
3516
+ });
3517
+
3518
+ it('stays silent about a shape, a video stream and a scene table that lose nothing', () => {
3519
+ // A SILENCE PROOF IS VACUOUS UNLESS THE CAPABILITY WAS ACTUALLY EXERCISED — silence because the
3520
+ // feature was absent proves nothing about the wire. So each assertion below is paired with a positive
3521
+ // check that the construct really was imported.
3522
+ const writer = new ShapeWriter();
3523
+ writer.writeSolidFillStyles([0x3366cc]);
3524
+ writer.writeLineStyleCount(0);
3525
+ writer.writeStyleBits(1, 0);
3526
+ writer.writeStyleChange({ fill1: 1, moveToX: 0, moveToY: 0 });
3527
+ writer.writeStraightEdge(400, 0);
3528
+ writer.writeStraightEdge(0, 400);
3529
+ writer.writeStraightEdge(-400, 0);
3530
+ writer.writeStraightEdge(0, -400);
3531
+ writer.writeEndShape();
3532
+
3533
+ const file = createSwf([
3534
+ createTag(TAG_DEFINE_SHAPE, joinBytes(uint16(7), createRectangle(0, 400, 0, 400), writer.toBytes())),
3535
+ createTag(
3536
+ TAG_DEFINE_VIDEO_STREAM,
3537
+ joinBytes(uint16(4), uint16(1), uint16(16), uint16(16), new Uint8Array([0x01, 2])),
3538
+ ),
3539
+ // A scene table with zero scenes still carries its label, so the tag is exercised and loses nothing.
3540
+ createTag(
3541
+ TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA,
3542
+ joinBytes(encodedUint32(0), encodedUint32(1), encodedUint32(0), swfString('start')),
3543
+ ),
3544
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(7))),
3545
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(2), uint16(4))),
3546
+ createTag(TAG_SHOW_FRAME),
3547
+ createTag(TAG_END),
3548
+ ]);
3549
+
3550
+ const diagnostics = collectImportDiagnostics((sink) => {
3551
+ const document = createScene2DFromSwf(file, sink);
3552
+ // The capabilities really were exercised: the shape drew, the video character materialized, and
3553
+ // the label survived.
3554
+ const children = getNodeChildren(document?.root as Node2D);
3555
+ expect(children.length).toBe(2);
3556
+ expect(getMovieClipCurrentLabel(document?.root as MovieClip)?.name).toBe('start');
3557
+ });
3558
+
3559
+ const kinds = diagnostics.map((entry) => entry.kind);
3560
+ expect(kinds).not.toContain('swf.shape-body-unreadable');
3561
+ expect(kinds).not.toContain('swf.video-frame-payload');
3562
+ expect(kinds).not.toContain('swf.scene-names');
3563
+ });
3564
+
3565
+ it('stays silent about a frame script and a mask that lose nothing', () => {
3566
+ const mask = new ShapeWriter();
3567
+ mask.writeSolidFillStyles([0xffffff]);
3568
+ mask.writeLineStyleCount(0);
3569
+ mask.writeStyleBits(1, 0);
3570
+ mask.writeStyleChange({ fill1: 1, moveToX: 0, moveToY: 0 });
3571
+ mask.writeStraightEdge(200, 0);
3572
+ mask.writeStraightEdge(0, 200);
3573
+ mask.writeStraightEdge(-200, 0);
3574
+ mask.writeStraightEdge(0, -200);
3575
+ mask.writeEndShape();
3576
+
3577
+ const file = createSwf([
3578
+ createTag(TAG_DEFINE_SHAPE, joinBytes(uint16(5), createRectangle(0, 200, 0, 200), mask.toBytes())),
3579
+ // One mask with real geometry over one instance: clip-depth is exercised and neither of its two
3580
+ // loss paths applies.
3581
+ createTag(
3582
+ TAG_PLACE_OBJECT_2,
3583
+ joinBytes(new Uint8Array([PLACE_HAS_CLIP_DEPTH | PLACE_HAS_CHARACTER]), uint16(1), uint16(5), uint16(3)),
3584
+ ),
3585
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(2), uint16(5))),
3586
+ // A block that is ONLY playback commands is recognized whole, so nothing is declined.
3587
+ createTag(TAG_DO_ACTION, new Uint8Array([0x07, 0x00])),
3588
+ createTag(TAG_SHOW_FRAME),
3589
+ createTag(TAG_END),
3590
+ ]);
3591
+
3592
+ const diagnostics = collectImportDiagnostics((sink) => {
3593
+ const document = createScene2DFromSwf(file, sink);
3594
+ expect(getMovieClipFrameScript(document?.root as MovieClip, 1)).not.toBeNull();
3595
+ });
3596
+
3597
+ const kinds = diagnostics.map((entry) => entry.kind);
3598
+ expect(kinds).not.toContain('swf.frame-script-declined');
3599
+ expect(kinds).not.toContain('swf.mask-without-geometry');
3600
+ expect(kinds).not.toContain('swf.nested-mask-collapsed');
3601
+ });
3602
+
3603
+ it('stays silent about a font, a spliced JPEG and a JPEG3 that lose nothing', () => {
3604
+ // The three capabilities that fire on the corpus without a silence proof yet. Each assertion is
3605
+ // paired with a positive check, so silence cannot come from the construct simply being absent.
3606
+ const glyph = new ShapeWriter();
3607
+ glyph.writeStyleBits(1, 0);
3608
+ glyph.writeStyleChange({ fill1: 1, moveToX: 0, moveToY: 0 });
3609
+ glyph.writeStraightEdge(256, 0);
3610
+ glyph.writeStraightEdge(0, 256);
3611
+ glyph.writeStraightEdge(-256, 0);
3612
+ glyph.writeStraightEdge(0, -256);
3613
+ glyph.writeEndShape();
3614
+ const glyphBytes = glyph.toBytes();
3615
+ const tables = new Uint8Array([0xff, 0xd8, 0xff, 0xdb, 0x00, 0x04, 0x11, 0x22, 0xff, 0xd9]);
3616
+ const jpeg3 = createJpegHeader(23, 17);
3617
+
3618
+ const file = createSwf([
3619
+ createTag(
3620
+ TAG_DEFINE_FONT_2,
3621
+ joinBytes(
3622
+ uint16(4),
3623
+ new Uint8Array([0, 0, 0]),
3624
+ uint16(1),
3625
+ joinBytes(uint16(4), uint16(4 + glyphBytes.length)),
3626
+ glyphBytes,
3627
+ new Uint8Array([0x41]),
3628
+ ),
3629
+ ),
3630
+ createTag(TAG_JPEG_TABLES, tables),
3631
+ createTag(TAG_DEFINE_BITS, joinBytes(uint16(9), createJpegHeader(11, 13))),
3632
+ // alphaDataOffset equal to the payload length: the colour stream is the whole body, so there is
3633
+ // no alpha block to discard and nothing is lost.
3634
+ createTag(TAG_DEFINE_BITS_JPEG_3, joinBytes(uint16(16), uint32(jpeg3.length), jpeg3)),
3635
+ // Placed, because an image earns a resource only when something samples it — without these the
3636
+ // silence below would be silence about constructs that never entered the document.
3637
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(1), uint16(9))),
3638
+ createTag(TAG_PLACE_OBJECT_2, joinBytes(new Uint8Array([PLACE_HAS_CHARACTER]), uint16(2), uint16(16))),
3639
+ createTag(TAG_SHOW_FRAME),
3640
+ createTag(TAG_END),
3641
+ ]);
3642
+
3643
+ const diagnostics = collectImportDiagnostics((sink) => {
3644
+ const document = createScene2DFromSwf(file, sink);
3645
+ // All three constructs really imported: two image resources plus a recoverable font.
3646
+ expect(document?.imageResources.length).toBe(2);
3647
+ expect(createGlyphOutlineSourcesFromSwf(file)?.size).toBe(1);
3648
+ });
3649
+
3650
+ const kinds = diagnostics.map((entry) => entry.kind);
3651
+ expect(kinds).not.toContain('swf.font-glyph-table');
3652
+ expect(kinds).not.toContain('swf.font-glyph-outline');
3653
+ expect(kinds).not.toContain('swf.jpeg-tables-missing');
3654
+ expect(kinds).not.toContain('swf.jpeg-tables-unsplittable');
3655
+ expect(kinds).not.toContain('swf.jpeg-alpha-stream');
3656
+ });
3657
+
3658
+ it('stays silent about an MP3 stream, whose blocks do concatenate', () => {
3659
+ // The last capability without a silence proof, and the only one that never fires on the sampled
3660
+ // corpus — so it is last by the ordering rather than unimportant. Format 2 is MP3 in the high nibble.
3661
+ const file = createSwf([
3662
+ createTag(18, joinBytes(new Uint8Array([0, 2 << 4]), uint16(1152))),
3663
+ createTag(19, joinBytes(uint16(1), uint16(0), new Uint8Array([0xff, 0xfb, 0x90, 0x00]))),
3664
+ createTag(TAG_SHOW_FRAME),
3665
+ createTag(TAG_END),
3666
+ ]);
3667
+
3668
+ const diagnostics = collectImportDiagnostics((sink) => {
3669
+ const document = createScene2DFromSwf(file, sink);
3670
+ // The stream really was carried: an MP3 head plus a block produces an audio resource.
3671
+ expect(document?.audioResources.length).toBe(1);
3672
+ });
3673
+
3674
+ expect(diagnostics.map((entry) => entry.kind)).not.toContain('swf.stream-sound-format');
3675
+ });
3676
+
3677
+ it('reports a label naming a frame the timeline never reaches, and stays silent when it does', () => {
3678
+ // The second way DefineSceneAndFrameLabelData loses data, found by auditing the CLAIM rather than
3679
+ // the wire: swf.scene-names covered the scene table only, while an out-of-range label was filtered
3680
+ // out silently and the capability still claimed trustworthy silence.
3681
+ const past = createSwf([
3682
+ createTag(
3683
+ TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA,
3684
+ joinBytes(encodedUint32(0), encodedUint32(1), encodedUint32(40), swfString('never')),
3685
+ ),
3686
+ createTag(TAG_SHOW_FRAME),
3687
+ createTag(TAG_END),
3688
+ ]);
3689
+ const dropped = collectImportDiagnostics((sink) => {
3690
+ expect(createScene2DFromSwf(past, sink)).not.toBeNull();
3691
+ }).filter((entry) => entry.kind === 'swf.label-past-last-frame');
3692
+ expect(dropped).toHaveLength(1);
3693
+ expect(dropped[0].severity).toBe(ImportDiagnosticSeverity.Drop);
3694
+ expect(dropped[0].detail).toEqual({ capability: 'swf.timeline.frame-label', dropped: 1, frames: 1 });
3695
+
3696
+ // A label the timeline does reach loses nothing, so no crumb — and it really did import.
3697
+ const reached = createSwf([
3698
+ createTag(
3699
+ TAG_DEFINE_SCENE_AND_FRAME_LABEL_DATA,
3700
+ joinBytes(encodedUint32(0), encodedUint32(1), encodedUint32(0), swfString('start')),
3701
+ ),
3702
+ createTag(TAG_SHOW_FRAME),
3703
+ createTag(TAG_END),
3704
+ ]);
3705
+ const quiet = collectImportDiagnostics((sink) => {
3706
+ const document = createScene2DFromSwf(reached, sink);
3707
+ expect(getMovieClipCurrentLabel(document?.root as MovieClip)?.name).toBe('start');
3708
+ });
3709
+ expect(quiet.map((entry) => entry.kind)).not.toContain('swf.label-past-last-frame');
3710
+ });
3711
+
3712
+ it('reports an unreadable body for every shape generation, not just the one the wire was written on', () => {
3713
+ // PROPERTY 4 APPLIED TO THE PROOF MAPPING: the shape wire was proven on DefineShape alone while the
3714
+ // mapping claimed all four generations. The code path is shared and parameterised by version, so what
3715
+ // was untested was the ROUTING — that each tag reaches it and reports its own capability id.
3716
+ const file = createSwf([
3717
+ createTag(TAG_DEFINE_SHAPE_2, joinBytes(uint16(2), createRectangle(0, 20, 0, 20), new Uint8Array([0xff]))),
3718
+ createTag(TAG_DEFINE_SHAPE_3, joinBytes(uint16(3), createRectangle(0, 20, 0, 20), new Uint8Array([0xff]))),
3719
+ createTag(
3720
+ TAG_DEFINE_SHAPE_4,
3721
+ joinBytes(uint16(4), createRectangle(0, 20, 0, 20), createRectangle(0, 20, 0, 20), new Uint8Array([0, 0xff])),
3722
+ ),
3723
+ createTag(TAG_SHOW_FRAME),
3724
+ createTag(TAG_END),
3725
+ ]);
3726
+ const diagnostics = collectImportDiagnostics((sink) => {
3727
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3728
+ });
3729
+
3730
+ const capabilities = diagnostics
3731
+ .filter((entry) => entry.kind === 'swf.shape-body-unreadable')
3732
+ .map((entry) => entry.detail?.capability);
3733
+ expect(capabilities).toEqual(['swf.shape.define-shape-2', 'swf.shape.define-shape-3', 'swf.shape.define-shape-4']);
3734
+ });
3735
+
3736
+ it('reports an unreadable glyph table for DefineFont and DefineFont3, not only DefineFont2', () => {
3737
+ const file = createSwf([
3738
+ createTag(TAG_DEFINE_FONT, joinBytes(uint16(6), new Uint8Array([0xff, 0xff]))),
3739
+ createTag(TAG_DEFINE_FONT_3, joinBytes(uint16(7), new Uint8Array([0xff, 0xff, 0xff, 0xff]))),
3740
+ createTag(TAG_SHOW_FRAME),
3741
+ createTag(TAG_END),
3742
+ ]);
3743
+ const diagnostics = collectImportDiagnostics((sink) => {
3744
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3745
+ });
3746
+
3747
+ const capabilities = diagnostics
3748
+ .filter((entry) => entry.kind === 'swf.font-glyph-table')
3749
+ .map((entry) => entry.detail?.capability);
3750
+ expect(capabilities).toEqual(['swf.font.define-font', 'swf.font.define-font-3']);
3751
+ });
3752
+
3753
+ it('reports a discarded alpha stream for DefineBitsJPEG4, whose header differs from JPEG3', () => {
3754
+ const jpeg4 = createJpegHeader(31, 19);
3755
+ const file = createSwf([
3756
+ // JPEG4 carries a deblocking uint16 between the offset and the payload, so its routing is a
3757
+ // genuinely different path from JPEG3's and needs its own proof.
3758
+ createTag(
3759
+ TAG_DEFINE_BITS_JPEG_4,
3760
+ joinBytes(uint16(17), uint32(jpeg4.length + 2), uint16(0), jpeg4, new Uint8Array([4, 5, 6])),
3761
+ ),
3762
+ createTag(TAG_SHOW_FRAME),
3763
+ createTag(TAG_END),
3764
+ ]);
3765
+ const diagnostics = collectImportDiagnostics((sink) => {
3766
+ expect(createScene2DFromSwf(file, sink)).not.toBeNull();
3767
+ });
3768
+
3769
+ const dropped = diagnostics.filter((entry) => entry.kind === 'swf.jpeg-alpha-stream');
3770
+ expect(dropped).toHaveLength(1);
3771
+ expect(dropped[0].detail).toEqual({
3772
+ capability: 'swf.bitmap.define-bits-jpeg-4',
3773
+ characterId: 17,
3774
+ discardedBytes: 3,
3775
+ });
3776
+ });
3777
+
3778
+ it('names the symbol a caller asked for that the file does not export', () => {
3779
+ const file = createSwf([createTag(TAG_SHOW_FRAME), createTag(TAG_END)]);
3780
+ const diagnostics = collectImportDiagnostics((sink) => {
3781
+ expect(createScene2DSymbolFromSwf(file, 'absent', sink)).toBeNull();
3782
+ });
3783
+
3784
+ expect(diagnostics.map((entry) => entry.kind)).toEqual(['swf.unknown-linkage-name']);
3785
+ expect(diagnostics[0].detail).toEqual({ linkageName: 'absent' });
3786
+ });
3787
+ });
3788
+
3789
+ describe('createScene2DFromSwf morph bounds', () => {
3790
+ it('reports the box the morph occupies at its ratio, not the union of both endpoints', () => {
3791
+ const startEdges = morphBox(200);
3792
+ const endEdges = morphBox(400);
3793
+ const styles = joinBytes(
3794
+ new Uint8Array([1, 0x00, 0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff]),
3795
+ new Uint8Array([0]),
3796
+ );
3797
+ const body = joinBytes(
3798
+ uint16(7),
3799
+ createRectangle(0, 200, 0, 200),
3800
+ createRectangle(0, 400, 0, 400),
3801
+ uint32(styles.length + startEdges.length),
3802
+ styles,
3803
+ startEdges,
3804
+ endEdges,
3805
+ );
3806
+ const place = (depth: number, ratio: number): Uint8Array =>
3807
+ createTag(
3808
+ TAG_PLACE_OBJECT_2,
3809
+ joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(depth), uint16(7), uint16(ratio)),
3810
+ );
3811
+
3812
+ const document = createScene2DFromSwf(
3813
+ createSwf([
3814
+ createTag(TAG_DEFINE_MORPH_SHAPE, body),
3815
+ place(1, 0),
3816
+ place(2, 0x7fff),
3817
+ place(3, 0xffff),
3818
+ createTag(TAG_SHOW_FRAME),
3819
+ createTag(TAG_END),
3820
+ ]),
3821
+ );
3822
+
3823
+ // The union would report 20 for all three. Each instance reports the box it is actually at, which is
3824
+ // what layout and hit testing read.
3825
+ const boxes = getNodeChildren(document!.root).map((node) => getNodeLocalBoundsRectangle(node).width);
3826
+ expect(boxes[0]).toBeCloseTo(10);
3827
+ expect(boxes[1]).toBeCloseTo(15, 1);
3828
+ expect(boxes[2]).toBeCloseTo(20);
3829
+ });
3830
+ });
3831
+
3832
+ describe('createScene2DFromSwf morph shapes', () => {
3833
+ it('places a morph character and drives its progress from the placement ratio', () => {
3834
+ const startEdges = morphBox(200);
3835
+ const endEdges = morphBox(400);
3836
+ const styles = joinBytes(
3837
+ new Uint8Array([1, 0x00, 0xff, 0x00, 0x00, 0xff, 0x00, 0x00, 0xff, 0xff]),
3838
+ new Uint8Array([0]),
3839
+ );
3840
+ const body = joinBytes(
3841
+ uint16(7),
3842
+ createRectangle(0, 200, 0, 200),
3843
+ createRectangle(0, 400, 0, 400),
3844
+ uint32(styles.length + startEdges.length),
3845
+ styles,
3846
+ startEdges,
3847
+ endEdges,
3848
+ );
3849
+
3850
+ const document = createScene2DFromSwf(
3851
+ createSwf([
3852
+ createTag(TAG_DEFINE_MORPH_SHAPE, body),
3853
+ // Two placements of one character at different ratios: the case a single shared node cannot serve.
3854
+ createTag(
3855
+ TAG_PLACE_OBJECT_2,
3856
+ joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(1), uint16(7), uint16(0)),
3857
+ ),
3858
+ createTag(
3859
+ TAG_PLACE_OBJECT_2,
3860
+ joinBytes(new Uint8Array([PLACE_HAS_CHARACTER | PLACE_HAS_RATIO]), uint16(2), uint16(7), uint16(0xffff)),
3861
+ ),
3862
+ createTag(TAG_SHOW_FRAME),
3863
+ createTag(TAG_END),
3864
+ ]),
3865
+ );
3866
+
3867
+ const [first, second] = getNodeChildren(document!.root) as MorphShape[];
3868
+ expect(first.kind).toBe(MorphShapeKind);
3869
+ expect(second.kind).toBe(MorphShapeKind);
3870
+ // Distinct nodes, each sitting at its own point along the same definition.
3871
+ expect(first).not.toBe(second);
3872
+ expect(first.data.progress).toBe(0);
3873
+ expect(second.data.progress).toBe(1);
3874
+ expect(morphWidth(first)).toBeCloseTo(10);
3875
+ expect(morphWidth(second)).toBeCloseTo(20);
3876
+ });
3877
+ });
3878
+
3879
+ describe('createScene2DImportFromSwf', () => {
3880
+ it('keeps an advanced blend mode off the node and reports it for a BlendEffect instead', () => {
3881
+ const result = createScene2DImportFromSwf(
3882
+ createSwf([
3883
+ createTag(
3884
+ TAG_PLACE_OBJECT_3,
3885
+ joinBytes(
3886
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_BLEND_MODE]),
3887
+ uint16(3),
3888
+ uint16(7),
3889
+ swfString('overlaid'),
3890
+ new Uint8Array([SWF_BLEND_OVERLAY]),
3891
+ ),
3892
+ ),
3893
+ createTag(TAG_SHOW_FRAME),
3894
+ createTag(TAG_END),
3895
+ ]),
3896
+ );
3897
+
3898
+ const overlaid = result!.document.slots[0].target;
3899
+ // Assigning an advanced mode to the node would render as a silent Normal, which is the whole reason
3900
+ // the two tiers are separate.
3901
+ expect(overlaid.blendMode).toBe(BlendMode.Normal);
3902
+ expect(result!.appearances).toEqual([
3903
+ { advancedBlendMode: AdvancedBlendMode.Overlay, effects: [], frame: 1, node: overlaid },
3904
+ ]);
3905
+ });
3906
+
3907
+ it('does not invent a trailing blend mode from an unknown filter payload', () => {
3908
+ const result = createScene2DImportFromSwf(
3909
+ createSwf([
3910
+ createTag(
3911
+ TAG_PLACE_OBJECT_3,
3912
+ joinBytes(
3913
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST | PLACE3_HAS_BLEND_MODE]),
3914
+ uint16(3),
3915
+ uint16(7),
3916
+ swfString('unknown-filter'),
3917
+ // Filter 0xfe is variable-width and unknown. Its first payload byte deliberately spells
3918
+ // Overlay; the real blend byte after it spells Multiply. Neither offset is safely reachable.
3919
+ new Uint8Array([1, 0xfe, SWF_BLEND_OVERLAY, SWF_BLEND_MULTIPLY]),
3920
+ ),
3921
+ ),
3922
+ createTag(TAG_SHOW_FRAME),
3923
+ createTag(TAG_END),
3924
+ ]),
3925
+ );
3926
+
3927
+ const node = result!.document.slots[0].target;
3928
+ expect(node.blendMode).toBe(BlendMode.Normal);
3929
+ expect(result!.appearances).toHaveLength(0);
3930
+ });
3931
+
3932
+ it('reports a placement filter list as effect descriptors and attaches nothing', () => {
3933
+ const result = createScene2DImportFromSwf(
3934
+ createSwf([
3935
+ createTag(
3936
+ TAG_PLACE_OBJECT_3,
3937
+ joinBytes(
3938
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST]),
3939
+ uint16(3),
3940
+ uint16(7),
3941
+ swfString('blurred'),
3942
+ new Uint8Array([1, 1]),
3943
+ uint32(4 * FIXED_16_ONE),
3944
+ uint32(2 * FIXED_16_ONE),
3945
+ new Uint8Array([0]),
3946
+ ),
3947
+ ),
3948
+ createTag(TAG_SHOW_FRAME),
3949
+ createTag(TAG_END),
3950
+ ]),
3951
+ );
3952
+
3953
+ const blurred = result!.document.slots[0].target;
3954
+ expect(result!.appearances).toHaveLength(1);
3955
+ expect(result!.appearances[0]).toMatchObject({ advancedBlendMode: null, frame: 1, node: blurred });
3956
+ expect(result!.appearances[0].effects[0]).toMatchObject({ blurX: 4, blurY: 2, kind: 'BlurEffect' });
3957
+ });
3958
+
3959
+ it('joins a colour-matrix filter onto the node adjustments, since a pointwise remap folds into the draw', () => {
3960
+ const cells: Uint8Array[] = [];
3961
+ for (let index = 0; index < 20; index++) cells.push(float32(index % 6 === 0 ? 1 : 0));
3962
+ const result = createScene2DImportFromSwf(
3963
+ createSwf([
3964
+ createTag(
3965
+ TAG_PLACE_OBJECT_3,
3966
+ joinBytes(
3967
+ new Uint8Array([PLACE_HAS_NAME | PLACE_HAS_CHARACTER, PLACE3_HAS_FILTER_LIST]),
3968
+ uint16(3),
3969
+ uint16(7),
3970
+ swfString('remapped'),
3971
+ new Uint8Array([1, 6]),
3972
+ ...cells,
3973
+ ),
2627
3974
  ),
2628
3975
  createTag(TAG_SHOW_FRAME),
2629
3976
  createTag(TAG_END),
@@ -2719,8 +4066,7 @@ describe('createScene2DSymbolFromSwf', () => {
2719
4066
  expect(symbol.slots[0].target).toBe(getNodeChildren(symbol.root)[0]);
2720
4067
  });
2721
4068
 
2722
- it('resolves the pixels of a bitmap the symbol draws, the way a placed one does', () => {
2723
- registerDeflateDecompressor();
4069
+ it('carries a lossless bitmap reference the symbol resource pass can resolve', async () => {
2724
4070
  const art = new ShapeWriter();
2725
4071
  art.writeFillStyleCount(1);
2726
4072
  art.writeBitmapFillStyle(0x41, 9, 20);
@@ -2744,11 +4090,17 @@ describe('createScene2DSymbolFromSwf', () => {
2744
4090
  ]),
2745
4091
  'Art',
2746
4092
  );
2747
- unregisterDecompressor(Compression.Deflate);
2748
-
2749
4093
  const drawn = symbol!.root as Shape;
2750
4094
  expect(drawn.data.commands[0]).toBe('beginTextureFill');
2751
- expectLosslessTexturePixel(drawn.data.commands[2] as Texture2D);
4095
+ const texture = drawn.data.commands[2] as Texture2D;
4096
+ expect(getTextureSource(texture)).toBeNull();
4097
+ expect(symbol!.imageResources).toHaveLength(1);
4098
+
4099
+ registerDeflateDecompressor();
4100
+ registerSwfImageDecoders();
4101
+ await loadScene2DImageResources(symbol!);
4102
+
4103
+ expectLosslessTexturePixel(texture);
2752
4104
  });
2753
4105
 
2754
4106
  it('hands an encoded bitmap out on the image-resource contract, naming the waiting texture', () => {
@@ -3041,6 +4393,10 @@ function signedBitCount(values: ReadonlyArray<number>): number {
3041
4393
  return 32;
3042
4394
  }
3043
4395
 
4396
+ function swfBytes(value: string): Uint8Array {
4397
+ return new TextEncoder().encode(value);
4398
+ }
4399
+
3044
4400
  function swfString(value: string): Uint8Array {
3045
4401
  return joinBytes(_encoder.encode(value), new Uint8Array([0]));
3046
4402
  }
@@ -3095,15 +4451,23 @@ const TAG_DEFINE_BITS = 6;
3095
4451
  const TAG_DEFINE_BUTTON_2 = 34;
3096
4452
  const TAG_DEFINE_EDIT_TEXT = 37;
3097
4453
  const TAG_DEFINE_FONT = 10;
4454
+ const TAG_DEFINE_MORPH_SHAPE_2 = 84;
4455
+ const TAG_DEFINE_TEXT_2 = 33;
3098
4456
  const TAG_DEFINE_FONT_2 = 48;
4457
+ const TAG_DEFINE_FONT_3 = 75;
3099
4458
  const TAG_DEFINE_FONT_INFO = 13;
3100
4459
  const TAG_DEFINE_SHAPE = 2;
4460
+ const TAG_DEFINE_SHAPE_2 = 22;
3101
4461
  const TAG_DEFINE_SHAPE_3 = 32;
4462
+ const TAG_DEFINE_SHAPE_4 = 83;
3102
4463
  const TAG_DEFINE_SPRITE = 39;
3103
4464
  const TAG_DEFINE_TEXT = 11;
3104
4465
  const TAG_DEFINE_VIDEO_STREAM = 60;
3105
4466
  const TAG_VIDEO_FRAME = 61;
4467
+ const TAG_DO_ABC = 82;
4468
+ const TAG_DO_ABC_ANONYMOUS = 72;
3106
4469
  const TAG_DO_ACTION = 12;
4470
+ const TAG_DO_INIT_ACTION = 59;
3107
4471
  const TAG_EXPORT_ASSETS = 56;
3108
4472
  const TAG_FILE_ATTRIBUTES = 69;
3109
4473
  const TAG_JPEG_TABLES = 8;