# Square's Weakest Link -- complete teardown A 2001 Flash movie distributed as a Windows `.exe`, opened all the way up: every asset decoded, every line of dialogue recovered, the whole timeline replayed frame by frame, and the content the movie never showed anyone catalogd. **Source:** `Weakest_Link_-_SE.exe`, 1,478,606 bytes. **What it turned out to be:** a Macromedia Flash 5 standalone projector with a 1.1 MB SWF welded onto the back of the player executable. **Status:** fully decoded. Nothing in the file is unaccounted for. | | | |---|---| | Title | Square's Weakest Link | | Author | **Kristopher M. Toops** -- "written, produced, directed & animated by" | | Music | **Shawn Overn** | | Proofreading credit | Lord Clodelroth, Comet Green | | Date | **2001** -- the end card reads `MMI -aka 2001 kmt.` | | Stage | 550 x 335 px, 12 fps, 273 frames | | Runtime | click-advanced; about 9-10 minutes at a reading pace | --- ## Contents 1. [What it is](#1-what-it-is) 2. [Provenance and identification](#2-provenance-and-identification) 3. [The container](#3-the-container) 4. [The movie](#4-the-movie) 5. [ActionScript -- all of it](#5-actionscript--all-of-it) 6. [Data inventory](#6-data-inventory) 7. [The script](#7-the-script) 8. [Hidden and never-visible content](#8-hidden-and-never-visible-content) 9. [Re-render and reconstruction](#9-re-render-and-reconstruction) 10. [Credits and rights](#10-credits-and-rights) 11. [How to reproduce this](#11-how-to-reproduce-this) 12. [The toolchain](#12-the-toolchain) 13. [About this teardown](#13-about-this-teardown) --- ## 1. What it is Kefka Palazzo hosts a parody of the quiz show *The Weakest Link* on the Final Fantasy VI opera-house stage, with eight Square protagonists as contestants: **Bartz** (FFV) * **Cecil** (FFIV) * **Crono** (Chrono Trigger) * **Edgar** (FFVI) * **Black Mage** (FF1) * **Edward** (FFIV) * **Kain** (FFIV) * **Terra** (FFVI) It is not a game. There is no scoring code, no state, no variables, and no branching -- nothing in the file evaluates an answer. It is a **click-advanced comic**: each panel of dialogue is one timeline frame that stops and waits for a mouse click, with six short animated interludes that play at full speed. Elimination order: 1. **Round 1** -- Edward is thrown off *before any question is asked and without a vote*, purely because Kefka feels like it. ("I am the host, and I say you are the weakest link, so Buh-bye Edward!") 2. **Round 2** -- Kain, voted off on near-unanimous "can't trust him" jokes. He votes for himself by accident and is refused a change of vote. 3. **Round 3** -- Cecil **and** Bartz tie, and Kefka eliminates both on the grounds that they are "obviously clones" of each other and of the original Final Fantasy Fighter. 4. **Round 4** -- Terra and the Black Mage both answer "I!" to *"those of you who use powerful magic, say I!"*, so Kefka declares both the weakest link. The Black Mage retaliates with **FIR2** ("Oooo... I feel slightly warmer than usual") and teleports out promising to return "with my cousin Vivi". 5. **Final** -- Crono beats Edgar 4-of-5. ### The central gag **Crono never speaks.** He is a silent protagonist, so the author gives him an *empty speech balloon* on every answer -- a stop-frame that places a balloon shape and no text at all. Kefka rules every blank correct, and Crono wins the show without uttering a word. Edgar's closing objection is the punchline: *"I object! He never answered a single question the entire time he was here!!"* This is visible in the data, not just on screen. The blank-balloon frames are **f51, f111, f155, f217, f222, f226 and f230** -- every one of them sits immediately after a question addressed to Crono and immediately before a "Correct." Other jokes that read as deliberate: the Final Fantasy IX teases (Vivi as the Black Mage's cousin; FFIX described as "a clone of the original Final Fantasy"), "Stiner" being marked wrong on spelling, and Kefka's fourth-wall line at f65 -- *"Fool, this is a comic, I can read all of the text even if you speak it."* One typo survived the credited proofreaders: at **f17** Edward (FFIV) is introduced as *"Edgar from Final Fantasy IV"*, though the script calls him Edward correctly from f26 onward. --- ## 2. Provenance and identification ### Hashes | File | Size | MD5 | |---|---:|---| | `Weakest_Link_-_SE.exe` | 1,478,606 | `5a640e7f6ca23a2666cb496466d15f45` | | extracted `.swf` | 1,101,766 | `4d629d942d36793a9a299f7a4329d8dc` | SHA-1 of the executable: `2e7ea8eef7a9006e40f75094db78152b34be46cd` ### The player stub Read straight out of the PE version resource: | Field | Value | |---|---| | OriginalFilename | `SwFlsh32.exe` | | ProductName | Flash 5.0 | | FileDescription | **Flash Player 5.0 r30** | | FileVersion | 5,0,30,0 | | CompanyName | Macromedia, Inc. | | LegalCopyright | Copyright (c) 1996-2000 Macromedia, Inc. | | PE timestamp | **2000-08-18 02:00:29 UTC** | | Machine / subsystem | i386 (0x014C) / GUI (2), linker 6.0 | | Sections | `.text` 281,388 * `.rdata` 31,832 * `.data` 18,044 * `.data1` 176 * `.rsrc` 38,608 | The stub is stock Macromedia code with nothing project-specific in it, which means **the `.exe` is disposable**: the extracted `.swf` plays identically in Ruffle or any Flash player and is the file worth keeping. ### Dating it, and why the executable says 2000 Everything in the table above belongs to `SwFlsh32.exe` -- **Macromedia's** binary, not the author's. Publishing a projector from Flash does not compile anything; it concatenates the unmodified stock player, the movie and an eight-byte footer. So the PE timestamp, the version resource and the 1996-2000 copyright all date *Macromedia's build of Flash Player 5.0 r30*. That makes 18 August 2000 a **floor**, not a date: the movie must be at least as new as the player used to wrap it, and can be arbitrarily newer. This is worth internalizing for any projector -- **the container's metadata describes the runtime vendor and never the content author.** Reading a projector's PE timestamp as the work's date will be wrong by months to years, every time. Three independent lines of evidence put the movie in 2001: 1. **The movie says so.** The final card reads `THE END.` / `MMI -aka 2001 kmt.` -- the Roman numeral with the year spelled out beside it and the author's initials. This is a statement, not an inference. 2. **The content cannot predate November 2000.** The script names Vivi, Steiner and Quina and asks two questions about Final Fantasy IX by name (f60, f160, f227). FFIX shipped in North America in November 2000 -- three months *after* the player stub was built. Whoever wrote these jokes had played it. 3. **The audio was encoded later than the player.** Both music MP3s carry the encoder fingerprint `LAME3.84 (alpha 1)` in their trailing frame -- stamped by the author's own encoding pass, from the LAME 3.8x series that postdates the August 2000 stub. (The 16 kbps mono sting, id 88, carries no tag.) A fourth, softer bound: the file is SWF version 5, so it was authored in Flash 5, which Macromedia released in August 2000. Every clock in the file except Macromedia's points at 2001. ### Where it came from, and who "kmt" was None of this is in the file -- it comes from the surrounding record, and it is the one part of this document that is not derived from the bytes. The movie was distributed through **Dream Horizon Software (DHS)**, a small American Final Fantasy fan-game group active around 2000-2001, and was hosted on their domain **dhsrpg.net** as a self-extracting executable. This copy was rediscovered on a personal fan site that had linked it with credit to DHS, and the original DHS source was then traced back through the Wayback Machine. DHS's own staff roster, archived in April 2001, lists a **DarthTOOPS** in its Public Relations department, described elsewhere on doors98 as the member "whose claim to fame outside of DHS was creating Square's Weakest Link". The movie's credits name **Kristopher M. Toops** and sign the end card `kmt`. Same surname, same credit, same year, same group -- DarthTOOPS and Kristopher M. Toops are almost certainly one person, and that is as far as the evidence goes. One correction to the surrounding record while we are here: DHS-era descriptions and doors98's own earlier writeup both call this "a fan game". It is not a game. It contains no scoring, no state, no branching and nothing that evaluates an answer -- sections 4 and 5 take that apart. It is a click-advanced comic, and the only thing in the file that responds to input is a single button whose entire behavior is `play()`. --- ## 3. The container A Flash projector is the stock player executable with the movie appended and an eight-byte footer: ``` offset 0x000000 +-------------------------------------+ | PE executable: Flash player stub | 376,832 bytes offset 0x05C000 +-------------------------------------+ | SWF movie (FWS, uncompressed) | 1,101,766 bytes offset 0x1692C6 +-------------------------------------+ | 56 34 12 FA | C6 CF 10 00 | 8-byte footer +-------------------------------------+ magic 0xFA123456 LE movie length ``` To split one: search backwards for `56 34 12 FA`, read the little-endian `u32` that follows as the movie length, and the movie starts that many bytes before the magic. Verify by checking for `FWS` / `CWS` / `ZWS` at the computed offset. `CWS` means the tag stream after the 8-byte header is zlib-deflated; `ZWS` means LZMA. This movie is `FWS` -- flat and uncompressed, which is unusually convenient. `tools/unpack_projector.py` normalizes all three to `FWS` so everything downstream can assume a flat stream. --- ## 4. The movie ### 4.1 Header ``` Signature FWS (uncompressed) Version 5 File length 1,101,766 -- matches actual; no truncation, no appended junk Stage 0..11000 x 0..6700 twips = 550 x 335 px Frame rate 12 fps Frames 273 Background #FFFFFF ``` There is no `Protect` tag, no `EnableDebugger`, no `Metadata`, no `ExportAssets`, no `FrameLabel`, and no compression. It is about as open as a SWF gets. A twip is 1/20 px. Every coordinate in the file is in twips; every matrix translation is in twips while the scale terms are 16.16 fixed point. Getting that mix wrong is the single most common way a SWF parser produces almost-right garbage. ### 4.2 Tag census -- 1,906 tags | Count | Tag | What it is here | |---:|---|---| | 628 | `PlaceObject2` | the entire "animation" -- swapping composite scene shapes per panel | | 317 | `ShowFrame` | 273 root frames + 44 inside movieclips | | 304 | `RemoveObject2` | tearing down the previous panel | | 211 | `DefineShape2` | composite scene layers and speech balloons | | 164 | `DoAction` | **every one is 2 bytes** (section 5) | | 136 | `DefineText` | the entire script | | 41 | `DefineShape` | single-bitmap holder rectangles | | 40 | `DefineBitsLossless2` | the ripped sprites, with alpha | | 28 | `StartSound` | music and SFX cues | | 8 | `SoundStreamHead2` | **dead** -- declared, never fed a `SoundStreamBlock` | | 8 | `End` | root plus 7 movieclips | | 7 | `DefineSprite` | the only real movieclips | | 6 | `DefineSound` | 3 MP3, 3 ADPCM | | 2 + 2 | `DefineFont` / `DefineFontInfo` | Times New Roman plus a 4-glyph novelty font | | 1 | `SetBackgroundColor` | white | | 1 | `DefineBitsLossless` | the stage backdrop, opaque | | 1 | `DefineButton2` | the **only** interactive object in the movie | | 1 | `DefineMorphShape` | the fade-to-credits wipe | 446 characters are defined in total, and **every one of them is referenced by something** -- a placement, a bitmap fill, a button state, a `StartSound`, or a text run. Nothing was defined and abandoned. ### 4.3 The interaction model One `DefineButton2` (character id 25) exists in the entire file. Its up, over, down and hit states are all the *same* 79 x 22 px rectangle (shape id 23), captioned by a static "Press" text object (id 24). Its only handler is: ``` on(release) { play(); // ActionScript opcode 0x06, one byte } ``` 164 of the 273 root frames carry a `DoAction`, and every single one is two bytes: * `07 00` -> `stop()` -- 154 frames * `09 00` -> `stopSounds()` -- 10 frames: 6, 23, 43, 69, 103, 124, 152, 165, 214 and 272, the round and scene boundaries where the music bed is cut before being restarted So the playback loop is: *frame draws -> `stop()` -> you click Press -> `play()` -> one frame advances -> `stop()` again.* ### 4.4 The six auto-play runs The Press button is placed only six times, and each placement covers one chapter: | Button placed | on depth | removed | covers | |---|---:|---|---| | f5 | 36 | f30 | Round 1 | | f42 | 37 | f84 | Round 2 | | f102 | 40 | f137 | Round 3 | | f150 | 32 | f177 | Round 4 | | f207 | 29 | f236 | Final round | | f268 | 28 | f272 | Credits | The gaps between them are the frame ranges with **no** `stop()`, which therefore run at the full 12 fps as cutscenes: ``` f1-4 title / opening f30-41 Edward is escorted off 1.0 s f84-101 Kain is escorted off 1.5 s f137-149 the "clones" are escorted off 1.1 s f177-206 Terra and the Black Mage exit 2.5 s f236-267 fade to credits 2.7 s ``` ### 4.5 Why it still looks animated while stopped `DefineSprite` movieclips keep running when the root timeline is stopped. The host's movieclip -- a 4-frame, 2-pose walk cycle -- is on the stage the whole time and keeps bobbing through every static dialogue panel. That is the only motion during the read-along sections, and it is why a naive "render frame N" approach that ignores movieclip playheads produces a subtly dead-looking reconstruction. ### 4.6 How the art is composited There is essentially **no vector art in this movie**. Each panel is a `DefineShape2` whose fill styles are a *stack of clipped-bitmap fills* -- one rectangle per character plus one for the backdrop, all inside a single shape. The shape used from f45, for instance: ``` shape 93 bounds 617 x 337 px, 20 fill styles, 25 edge segments bitmap fills: 9 (backdrop), 8, 7, 6, 4, 3, 2, 1 + 29 (host) ``` To "animate" a character leaving, the author defines a *new* composite shape with one fewer bitmap fill and swaps it in with `PlaceObject2`. That is why the file has 211 `DefineShape2` tags and almost no real geometry: of the 252 shapes exported to SVG, the overwhelming majority are between one and nine axis-aligned rectangles carrying a bitmap. The genuine vector objects are the **speech balloons** -- a white solid fill, a 1 px black line style, and a pointer tail: ``` shape 19 bounds 285.6..534.6 x 107.5..238.6 px 1 fill solid #FFFFFF 1 line width 20 twips = 1 px, #000000 4 edge segments ``` ### 4.7 The movieclips Seven `DefineSprite` tags, all of them simple bitmap flipbooks: | sprite | frames | bitmaps | what it is | |---:|---:|---|---| | 15 | 4 | 11, 13 | Kefka walk cycle, pose pair A | | 20 | 4 | 11, 13 | the same cycle, second instance | | 83 | 4 | 79, 81 | Kefka walk cycle, pose pair B -- **never visible**, see section 8 | | 231 | 4 | 227, 229 | Edgar walk cycle | | 342 | 8 | 334, 336, 338, 340 | Terra walk cycle A | | 352 | 8 | 344, 346, 348, 350 | Terra walk cycle B | | 385 | 12 | 361..383 | the fire spell -- Black Mage's "FIR2" | ### 4.8 The credits transition `DefineMorphShape` id 436 is a full-stage **solid black rectangle** (start bounds -26.5..574.6 x -28.1..366.6 px, end bounds essentially identical, fill RGBA `0,0,0,255` -> `0,0,0,255`). It is placed on depth 27 at f236 and then re-placed once per frame with `ratio` stepping `2048, 4096, 6144 ...` up through 65535 over 32 frames -- a plain wipe to black before the credit cards. --- ## 5. ActionScript -- all of it There is nothing to reverse-engineer. The complete ActionScript inventory of this 1.1 MB movie is: ``` 154 x stop() opcode 0x07 10 x stopSounds() opcode 0x09 1 x play() opcode 0x06, on the button's release handler ``` No `constantPool`, no `defineFunction`, no `getURL`, no `setVariable`, no `gotoFrame`. The author built the whole thing on the Flash 5 timeline with the Actions panel in "Basic Actions" mode. The same is true at the placement level. Across all 628 `PlaceObject2` tags, **not one** sets a color transform, an instance name, a clip depth, or clip actions; only 48 set a ratio, and those are the credits morph. So nothing anywhere is faded, tinted, masked or named, and there are no `onClipEvent` handlers hiding outside the `DoAction` tags. The one button really is the movie's entire interactive surface. Full disassembly: `extract/actionscript_disasm.txt`. --- ## 6. Data inventory ### 6.1 Bitmaps -- 41 All are `DefineBitsLossless2` (zlib, 8-bit palette, format 3, with alpha) except id 9, which is `DefineBitsLossless` and opaque. Every one is ripped Square sprite art -- mostly SNES-era, with the Black Mage in the later remake/WonderSwan style. Character identifications are mine, from the art cross-checked against which bitmap ids appear in the composite shape for each scene and who is speaking in it. | id | size | subject | |---:|---|---| | 1 | 16x24 | **Kain Highwind** -- Final Fantasy IV dragoon | | 2 | 16x24 | **Bartz/Butz Klauser** -- Final Fantasy V | | 3 | 16x24 | **Cecil Harvey**, Paladin -- Final Fantasy IV | | 4 | 16x24 | **Edgar Roni Figaro** -- Final Fantasy VI | | 5 | 16x24 | **Edward Chris von Muir** -- Final Fantasy IV, the bard | | 6 | 38x34 | **Crono** -- Chrono Trigger, mid-swing katana pose | | 7 | 15x24 | **Terra Branford** -- Final Fantasy VI | | 8 | 17x26 | **Black Mage** -- original Final Fantasy | | **9** | **240x147** | **the backdrop** -- the Final Fantasy VI opera house: red curtains, tiered audience of NPC sprites | | 11, 13 | 16x24 | **Kefka Palazzo** walk cycle, pose pair A | | 29, 66, 68 | 16x24 | Kefka, additional poses | | 79, 81 | 16x24 | Kefka walk cycle, pose pair B -- **never rendered**, section 8 | | 171, 212, 227, 229, 239 | 16x24 | Edgar, additional poses and walk cycle | | 334, 336, 338, 340 | 15x24 | Terra walk cycle A | | 344, 346, 348, 350 | 15x24 | Terra walk cycle B | | 361-383 (12) | 48x48 | **fire spell animation**, 12 frames -- "FIR2" | ### 6.2 Sounds -- 6 | id | codec in SWF | extracted as | rate | ch | length | cues | role | |---:|---|---|---|---:|---|---:|---| | 16 | MP3 128 kbps | `snd_016.mp3` | 44.1 kHz | 2 | 3.81 s | 10 | round-transition sting | | 31 | MP3 128 kbps | `snd_031.mp3` | 44.1 kHz | 2 | **54.88 s** | 9 | **the main theme bed** | | 88 | MP3 16 kbps | `snd_088.mp3` | 11.025 kHz | 1 | 2.06 s | 6 | elimination sting | | 359 | **ADPCM** | `snd_359.wav` | 5.512 kHz | 1 | 1.61 s | 1 | spell cast, f209 | | 387 | **ADPCM** | `snd_387.wav` | 5.512 kHz | 1 | 1.04 s | 1 | impact, f210 | | 393 | **ADPCM** | `snd_393.wav` | 5.512 kHz | 1 | 5.32 s | 1 | the "POOF!" exit, f212 | The three ADPCM sounds needed a from-scratch Flash-ADPCM decoder -- see section 11.5. All three decode to **exactly** their declared sample counts (8,882 / 5,709 / 29,316), which is a clean self-check. Both music MP3s carry `LAME3.84 (alpha 1)` in their trailing frame -- the encoder the author ran the audio through, and one of the three clocks that dates the movie to 2001 rather than to the player stub's 2000 (section 2). The 8 `SoundStreamHead2` tags are vestigial: they declare 5.512 kHz / 8-bit / mono with `samples_per_frame = 0`, and no `SoundStreamBlock` ever follows. Flash's authoring tool emitted one for every movieclip. There is no streamed audio in this file. ### 6.3 Vector shapes -- 252 Every `DefineShape` and `DefineShape2` is converted to a standalone SVG with correct fill winding, plus `shapes/_index.json` listing bounds, fill and line counts, and which bitmap ids each shape references. Bitmap-fill paths emit `fill="url(#bmpNNN)"`; pair them with the matching PNG if you want a self-contained SVG. ### 6.4 Fonts -- 2 | id | name | glyphs | charset | |---:|---|---:|---| | 21 | Times New Roman | 67 | `` !"&',-.01245:?ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz`` | | 391 | Times New Roman | **4** | `!FOP` | Font 391 is a 4-glyph subset embedded for exactly one word: the giant white **"POOF!"** at frame 212. Note that font 21's charset has no `3`, `6`, `7`, `8`, `9`, `;` or `(` -- the author only ever embedded the glyphs the script actually used, which is why the whole screenplay fits in 67 glyphs. ### 6.5 Text -- 136 records All dialogue is **static** `DefineText` -- glyph indices plus advances, with no `DefineEditText` anywhere. This is why a strings dump of the executable finds nothing readable. See section 11.3. --- ## 7. The script Every line of dialogue in the movie, in playback order, recovered from the 136 `DefineText` tags and keyed to the main-timeline frame each one is *placed* on. Panels marked **[silent balloon]** stop and wait for a click but place no text. In the Q&A rounds every one of them is Crono answering a question - he is a silent protagonist in *Chrono Trigger* and the movie never lets him speak. Kefka accepts the blank balloon as correct every single time, and Crono goes on to win the show. ### Frames 1-4 - Title / opening animation (auto-play, 12 fps) *(no dialogue - continuous animation)* ### Frames 5-29 - ROUND 1 - introductions, and Edward is dismissed - **f5** - Hello and welcome again to another fine episode of Square's Weakest Link. I am your great and all powerful host, Kefka! - **f6** - Let's meet our eight great contestants shall we? On the right, from Final Fantasy V we have Bartz! - **f7** - *[held panel, no new text]* - **f8** - Thank you, it is good to be here Mr. Kefka. - **f9** - Cecil from Final Fantasy IV - **f10** - Is it just me or does Bartz look like he could be my son?? - **f11** - From ChronoTrigger, Crono - **f12** - *[held panel, no new text]* - **f13** - One of my enemies from Final Fantasy VI, say hello to Edgar! - **f14** - Didn't we beat you? - **f15** - Moving to the left of the room, we have the popular Black Mage from the original Final Fantasy - **f16** - WASSUP! - **f17** - One of the suckiest characters to ever be in a game, Edgar from Final Fantasy IV. - **f18** - C'mon, was I really that bad?? - **f19** - You can't trust this next guy, it's Kain from Final Fantasy IV - **f20** - How many times must I say I'm sorry? Yeesh! - **f21** - Lastly, you may know her as that girl that freaked out in Final Fantasy VI, Terra. - **f22** - Thank you for having me on the show today. - **f23** - You all know the rules, so let's begin round one. - **f24** - *[held panel, no new text]* - **f25** - *[held panel, no new text]* - **f26** - Edward, you are the weakest link, Good-bye! - **f27** - But you didn't even ask me a question yet, and no one voted me off the show... - **f28** - I am the host, and I say you are the weakest link, so Buh-bye Edward! - **f29** - Fine. ### Frames 30-41 - Animation: Edward walks off (auto-play) *(no dialogue - continuous animation)* ### Frames 42-83 - ROUND 2 - Q&A, voting, Kain eliminated - **f42** - *[held panel, no new text]* - **f43** - Let's begin round two. - **f44** - *[held panel, no new text]* - **f45** - Bartz, who was the main villian of Final Fantasy IV? - **f46** - Zeromus - **f47** - Correct! Cecil, what game was Lavos in? - **f48** - Chrono Trigger - **f49** - Correct. - **f50** - Crono, what is your most famous line in Chronotrigger? - **f51** - *[Crono answers - empty speech balloon]* - **f52** - Correct. Edgar, who composes music for the Final Fantasy games? - **f53** - Hey, wait just a minute! Crono's question wasn't fair. - **f54** - Too bad, just answer the question. Who composes music for Final Fantasy? - **f55** - Bahamut. - **f56** - Incorrect you stupid nitwit, it is Nobuo Uematsu. - **f57** - *[held panel, no new text]* - **f58** - Black Mage, what is my famous deadly spell? - **f59** - That would be Light of Judgement. - **f60** - Correct. Kain, name two characters from Final Fantasy IX. - **f61** - Vivi, and ummm.... ummm..... - **f62** - Stiner . - **f63** - Incorrect, you spelled Steiner wrong. - **f64** - Hey, I said Steiner, I didn't spell it out! - **f65** - Fool, this is a comic, I can read all of the text even if you speak it. Buwahahahahaha! - **f66** - Terra, what hideous beast do you call daddy? - **f67** - That's an easy one. Maduin. - **f68** - Correct. - **f69** - Now it is time to vote off who you think is the weakest link. Give names and explanations. - **f70** - *[held panel, no new text]* - **f71** - I vote Kain. I don't know if I can trust him . - **f72** - Kain. I know I can't trust him. - **f73** - *[held panel, no new text]* - **f74** - Ummm... Is it my turn? Ok. I vote Crono, he hasn't said anything yet. - **f75** - My vote is for Bartz. He is obviously a clone of Cecil, who himself is a clone of the original Final Fantasy fighter. - **f76** - Kain, becau... - **f77** - Hey, wait a minute I'm Kain!! Can I change my vote? - **f78** - He he. No you can't!! Muwahahaha! - **f79** - Edgar, he has a bad attitude today. - **f80** - Whatever. - **f81** - Kain, you have been voted the weakest link by a majority of your peers. Do you have anything to say? - **f82** - I still don't understand why people don't trust me. - **f83** - Kain, you are the weakest link. Good-bye! ### Frames 84-101 - Animation: Kain walks off (auto-play) *(no dialogue - continuous animation)* ### Frames 102-136 - ROUND 3 - Q&A, voting, Cecil AND Bartz eliminated - **f102** - *[held panel, no new text]* - **f103** - Time for round three of Square's Weakest Link! - **f104** - *[held panel, no new text]* - **f105** - Bartz, who said "You spoony bard"? - **f106** - Sephiroth. - **f107** - Incorrect, Tellah said "you spoony bard." - **f108** - Cecil, what video game are you in? - **f109** - Final Fantasy II. - **f110** - Incorrect, you are in Final Fantasy IV. Crono, what sound do oxygen atoms make? - **f111** - *[Crono answers - empty speech balloon]* - **f112** - Correct. Edgar, name a famous Moogle. - **f113** - Mog. - **f114** - Correct. - **f115** - Black Mage, name a character that has appeared in almost all Final Fantasys. - **f116** - Cid. - **f117** - Correct. - **f118** - Terra, is Edgar god's gift to women? - **f119** - *[held panel, no new text]* - **f120** - No. - **f121** - WHAT?!?! - **f122** - Wait a minute I object to that last question and answer! - **f123** - Too bad, she was correct. Uwehehehe. - **f124** - Time to vote off the next loser.... Err... I mean Weakest Link. - **f125** - *[held panel, no new text]* - **f126** - Cecil, because he answered wrong. - **f127** - Bartz, because he is a doofis. - **f128** - *[held panel, no new text]* - **f129** - Crono, he is not talking. - **f130** - I vote Cecil and Bartz, they are clones of the original Final Fantasy Fighter I tell you!! - **f131** - Black Mage. His spells don't compare to mine. - **f132** - Since Cecil and Bartz have the same amount of votes, and they are obviously clones... - **f133** - They will both be the weakest link! - **f134** - WE CLONES WILL RULE THE WORLD! - **f135** - Yeah, whatever. Only I Kefka will rule the world. - **f136** - Good-Bye weakest links!! ### Frames 137-149 - Animation: the clones walk off (auto-play) *(no dialogue - continuous animation)* ### Frames 150-176 - ROUND 4 - Q&A, voting, Terra and Black Mage eliminated - **f150** - *[held panel, no new text]* - **f151** - Poor unanimated fools. - **f152** - Time for round four. - **f153** - *[held panel, no new text]* - **f154** - Crono, what is written on a fresh sheet of paper? - **f155** - *[Crono answers - empty speech balloon]* - **f156** - Correct. Edgar who is your brother? - **f157** - Sabin. - **f158** - Correct. - **f159** - Black Mage, what video game is said to be a clone of the original Final Fantasy? - **f160** - Final Fantasy IX. - **f161** - Correct. - **f162** - Terra, who is the king of the dragons? - **f163** - Odin. - **f164** - *[held panel, no new text]* - **f165** - Incorrect. Time to vote again. - **f166** - *[held panel, no new text]* - **f167** - *[held panel, no new text]* - **f168** - Crono, he hasn't even opened his mouth for god's sake. - **f169** - Terra, she doen't know the meaning of black magic. - **f170** - Black Mage, yes I do you ancient fool! - **f171** - Hmmmmm.... - **f172** - Those of you who use powerful magic, say I! - **f173** - I!! - **f174** - Being an evil villian and the host of the show, I declare you both the weakest links!! - **f175** - Bwahahahahahaha! - **f176** - This game sucks anyway. I'm outta here. ### Frames 177-206 - Animation: exits, Black Mage lines up his spell (auto-play) *(no dialogue - continuous animation)* ### Frames 207-235 - FINAL ROUND - Crono vs Edgar - **f207** - *[held panel, no new text]* - **f208** - Take this evil villian!! - **f209** - FIR2!! - **f210** - Oooo... I feel slightly warmer than usual. - **f211** - I will leave, but I shall return later with my cousin Vivi. Muwahahahaha! - **f212** - POOF! - **f213** - *[held panel, no new text]* - **f214** - *[held panel, no new text]* - **f215** - Time for the Final round. Who ever answers the most out of 5 questions correctly wins. - **f216** - Crono, if you speak in space, what will people hear? - **f217** - *[Crono answers - empty speech balloon]* - **f218** - Correct. Edgar, who was the most evil villian in the Final Fantasy Series? - **f219** - Zeromus. - **f220** - *[held panel, no new text]* - **f221** - Incorrect, it was me! Crono, what does dead silence sound like? - **f222** - *[Crono answers - empty speech balloon]* - **f223** - Correct. Edgar, what was the most pathetic enemy in the Final Fantasy Series? - **f224** - Imps. - **f225** - Correct. Crono, what did Robo say when he was powered off. - **f226** - *[Crono answers - empty speech balloon]* - **f227** - Correct. Edgar, in what video game can you find the character Quina? - **f228** - Chrono Trigger - **f229** - Incorrect. Crono, what did you say to Marle at the very end of Chrono Trigger? - **f230** - *[Crono answers - empty speech balloon]* - **f231** - Correct! Crono, you have answered 4 out of 5 questions correctly, and Edgar has missed 2 questions.... - **f232** - Crono is the newest winner on Square's Weakest Link!! - **f233** - I object! He never answered a single question the entire time he was here!! - **f234** - He he. That's too bad Eddie! Muwahahahahaha! - **f235** - Well, all my loyal subjects. Good-bye until next time. ### Frames 236-267 - Animation: transition to credits (morph shape, auto-play) *(no dialogue - continuous animation)* ### Frames 268-273 - CREDITS - **f268** - Square's Weakest Link was written, produced, directed & animated by: - **f268** - Kristopher M. Toops - **f269** - Music for Square's Weakest Link was written and provided by: - **f269** - Shawn Overn - **f270** - Special thanks to the following individuals who noticed typos and saved me from looking like an idiot: Lord Clodelroth Comet Green - **f271** - All characters that appeared in Square's Weakest Link are property of Squaresoft Inc., and were not harmed in any way shape or form during the production of Square's Weakest Link. - **f272** - THE END. MMI -aka 2001 kmt. - **f273** - *[held panel, no new text]* --- ## 8. Hidden and never-visible content Swept four ways. Three came back clean; the fourth found something. ### Clean: no payload, no metadata, no deleted script * **No container slack.** The last root tag (`End`) ends at byte 1,101,766 -- exactly the declared file length. Zero trailing bytes inside the SWF, and exactly 8 bytes (the footer) after it in the executable. Nothing is hiding in the gaps because there are no gaps. * **No appended data in the images.** Every `DefineBitsLossless2` zlib stream was decompressed and checked for `unused_data`. All 41 came back with zero trailing bytes. * **No author metadata.** The SWF contains no `.fla` name, no `C:\` path, no URL and no email address. Flash 5 embedded none of that, and the author added none. The credits are the only place his name appears anywhere in the file. There is exactly one tool fingerprint in the whole movie, and it is not Flash's: both music MP3s end with `LAME3.84 (alpha 1)`, stamped by the encoder the author ran the audio through. It is useful for dating (section 2) but says nothing about who he was. * **No deleted dialogue.** Both fonts were checked glyph by glyph against every `DefineText`: **all 67 glyphs of font 21 and all 4 of font 391 are actually used**. If a cut line had contained a character used nowhere else, an orphan glyph would have survived in the font. None did. * **No orphaned characters.** All 446 defined character ids are referenced. * **No hidden text or layers.** Every one of the 136 text objects sits fully inside the visible stage, and no placement carries a color transform, instance name, clip depth or clip actions. ### Found: a large off-stage parking lot The stage is 550 x 335 px, but the authored canvas is wider. **302 bitmap rectangles are placed entirely outside it** and were never rendered to screen by any Flash player. **1. Pose palettes.** The author kept a vertical stack of alternate poses just outside each edge and dragged the one he wanted into frame for each panel, leaving the rest parked. Every composite scene shape carries the whole stack: | bitmap | who | on-stage | **off-stage** | parked at | |---:|---|---:|---:|---| | 68 | Kefka | 7 | **98** | x 568..621, right | | 29 | Kefka | 58 | **78** | x 565..622, right | | 171 | Edgar | 1 | **72** | x -57..-20, left | | 212 | Edgar | 1 | **57** | x -63..-21, left | | 239 | Edgar | 4 | **47** | x -62..-21, left | | 66 | Kefka | 14 | **22** | x 573..620, right | Kefka's spares live off the right edge, Edgar's off the left, spanning nearly the full height of the canvas. **2. The eliminated contestants never actually leave.** The walk-off cutscenes move each sprite out through the stage edge -- and then it simply stays there, standing in the void, for the rest of the runtime: | character | eliminated | parked from | position | |---|---|---|---| | Edward (FFIV) | Round 1, f26-29 | f42 | x -43..-11, off the left | | Kain | Round 2, f81-83 | f102 | x -52..-21, off the left | | Cecil | Round 3, f132-136 | f150 | x 574..608, off the right | | Bartz | Round 3, f132-136 | f150 | x 612..646, off the right | | Terra | Round 4, f174 | f177 | x -51..-21, off the left, through f212 | **3. Two bitmaps were never once rendered to screen.** Bitmaps **79 and 81** -- a second Kefka walk-cycle pose pair -- have an on-stage placement count of **zero** across all 273 frames. They live in `DefineSprite` id 83, a working 4-frame, 2-pose walk cycle. It is placed on the root twice, both times at 2.03x scale: ``` f29 depth 33 tx = 582.5 px -> occupies x 582.5 .. 615.0 f136 depth 24 tx = 570.0 px -> occupies x 570.0 .. 602.5 ``` Shapes 80 and 82 are 16 x 24 px with their origin at the top left, so both placements sit entirely past the 550 px right edge. And the second placement is **never removed** -- so from f136 onward a fully animated Kefka keeps walking in place, just off the right of frame, through the elimination of the clones, the entire final round, the fade-out, the credit cards and the "THE END." card. He is still going when the movie stops. Those two PNGs effectively did not exist until this extraction. --- ## 9. Re-render and reconstruction `render.py` replays the root display list frame by frame and rasterizes it with a hand-written nonzero-winding scanline filler, sampling bitmap fills through the inverted fill matrix. It renders text by pulling the actual glyph outlines out of `DefineFont` rather than substituting a system font, so the output is the movie's own typography. Outputs: * **`extract/frames/frame_001.png` ... `frame_273.png`** -- all 273 frames at stage size. * **`extract/_overscan/`** -- the same 273 frames on a padded canvas, which is what makes the parked art in section 8 visible. * **`extract/Weakest_Link_reconstruction.mp4`** -- 9 min 37 s, upscaled with nearest-neighbour, dialogue panels held proportional to their text length, cutscene runs played at the true 12 fps, theme music underneath. * **`extract/viewer.html`** -- a click-to-advance frame viewer that mirrors the original projector's interaction. **Known fidelity limits.** The stroke pass is disabled in the shipped renders: a quad-per-segment stroke approximation produced stray construction lines on some composite shapes, and the fills alone reproduce the art faithfully because balloon outlines are carried by fill geometry in most shapes. Gradient fills fall back to a solid end-stop color -- there are almost none here. Movieclip playheads are advanced relative to their placement frame, so walk cycles are in a plausible but not frame-authoritative phase. These are reconstruction artifacts, not properties of the movie. --- ## 10. Credits and rights From the movie's own end cards, verbatim: > Square's Weakest Link was written, produced, directed & animated by: > **Kristopher M. Toops** > > Music for Square's Weakest Link was written and provided by: > **Shawn Overn** > > Special thanks to the following individuals who noticed typos and saved me > from looking like an idiot: **Lord Clodelroth**, **Comet Green** > > All characters that appeared in Square's Weakest Link are property of > Squaresoft Inc., and were not harmed in any way shape or form during the > production of Square's Weakest Link. > > THE END. `MMI -aka 2001 kmt.` The sprite art is ripped from Squaresoft games (Final Fantasy IV, V and VI, Chrono Trigger) and the backdrop from the Final Fantasy VI opera house. Those assets remain the property of their rights holder; they are catalogd here for preservation and study, exactly as the movie's own disclaimer anticipates. The player stub is Macromedia's. The movie itself is Kristopher M. Toops's work and the music is Shawn Overn's. Takedown requests from any rights holder named here are welcome. --- ## 11. How to reproduce this The SWF format is public and simple. Everything below is derived from the file format spec -- no third-party SWF tooling (`ffdec`, `swfmill`, `ruffle`) was used at any point, which is what makes the toolchain portable to any other Flash-era projector. ### 11.1 The container Search backwards for `56 34 12 FA`; the little-endian `u32` after it is the movie length; the movie starts that many bytes earlier. Sanity-check for `FWS`/`CWS`/`ZWS` at that offset and confirm the byte after the signature is a plausible SWF version. `CWS` is zlib from byte 8; `ZWS` is LZMA. Normalize to `FWS` before anything else. ### 11.2 The tag stream Header is `signature[3], u8 version, u32 fileLength`, then a bit-packed `RECT` for the stage, `u16` frame rate as 8.8 fixed point, and `u16` frame count. Then tags: `u16 (code << 6) | length`, and if `length == 0x3F` a `u32` long length follows. Two traps, both of which produce plausible-looking wrong answers rather than crashes: * **Bit-field alignment.** After a bit-packed structure like `RECT` you must advance to the next whole byte. A reader that only zeroes its bit pointer without incrementing the byte offset reads the frame rate from the wrong place -- the symptom is `fps = 0` and a nonsense frame count, with the tag stream then decoding as garbage. * **`DefineSprite` is recursive.** Its body is `u16 id, u16 frameCount` followed by a nested tag stream. Walk into it, and keep a depth marker so you can tell root-timeline tags from movieclip tags later. ### 11.3 Static text is the hard part All the dialogue is `DefineText`, which stores **glyph indices, not characters**. To recover the script: 1. Parse `DefineFont` to learn the glyph count from the first entry of its offset table (`offset[0] / 2 == number of glyphs`). 2. Parse `DefineFontInfo` for the code table that maps glyph index -> character code. The wide-codes flag in its bit field decides `u8` vs `u16` entries. 3. Walk the `DefineText` records. Each carries `glyphBits` and `advanceBits` read as raw bit fields, and a chain of style-change records that set font, size, color and position. Map each glyph index through the font's code table. Nothing readable exists in the file until step 3 lands. That is the whole reason these movies look opaque. ### 11.4 Shapes and the display list Shape records are a bit-packed edge list with two fill-style slots per edge (left and right). To fill correctly, collect edges per fill index, **reverse the ones referenced by the left slot**, chain them end-to-start into closed loops, and fill with the nonzero winding rule. The one that bites: `DefineShape2` and `DefineShape3` can emit a `StateNewStyles` record mid-shape that appends *new* fill and line arrays and restarts style numbering at 1 relative to them. If you treat those indices as absolute you get the right geometry with the wrong colors on part of the shape. For bitmap fills, the fill matrix maps bitmap-pixel space to shape twip space, so a 1:1 bitmap has a scale of about 20. Compose it with the placement matrix, invert, and sample. Using it in the wrong direction produces a recognisable failure: the image appears as a handful of enormous colored blocks. Reproducing a frame means simulating the display list -- `PlaceObject2` with its move flag, character id, matrix and depth; `RemoveObject2` by depth; render in ascending depth order on `ShowFrame`. Movieclip playheads advance independently of the root, so a clip placed at frame P shows its frame `((N - P) mod frameCount) + 1` at root frame N. ### 11.5 Flash ADPCM Not IMA ADPCM, though it is close. The `DefineSound` payload begins with `u16 sampleCount`-adjacent header bytes, then: 2 bits of code size (giving 2, 3, 4 or 5-bit codes), then blocks of 4096 samples. Each block starts with a signed 16-bit initial sample and a 6-bit step index, then 4095 codes. The reconstruction is the standard IMA step-table walk, with the index-adjustment table selected by code width. The self-check is exact: decoded sample count must equal the count declared in the tag. If it does not, the code width or block length is wrong. ### 11.6 Finding the hidden content This is the part worth doing deliberately, because it is where these files reward you: 1. **Orphan scan.** Collect every defined character id and every reference to one, and diff. 2. **Off-stage scan.** Transform each shape's bitmap-fill rectangles by the placement matrix and compare against the stage rect. Anything wholly outside is content no viewer ever saw. 3. **Visibility ledger.** For each bitmap, count placements that land inside the stage versus outside. A bitmap with an on-stage count of zero was never displayed at all. 4. **Byte sweep.** Check for slack after the last tag, trailing bytes after each zlib stream, and any human-written strings. 5. **Glyph sweep.** Compare embedded glyphs against glyphs actually typed; leftovers are evidence of cut text. 6. **Overscan render.** Re-render every frame on a padded canvas. This is the step that turns the numbers into something you can look at. ### 11.7 A prompt for doing this yourself Hand an agent the file and something like this: > I have a Windows executable that is a Macromedia Flash projector -- the Flash > player with a SWF appended. I want a complete static teardown: every bitmap, > sound, shape and piece of text decoded to modern formats, the timeline > explained, and every frame re-rendered. Do not run the program. Write the > parsers yourself from the SWF specification; do not use ffdec, swfmill or > ruffle. > > Work in this order and verify each step before moving on: > > 1. **Container.** Search backwards for the footer magic `56 34 12 FA`; the > little-endian u32 after it is the movie length and the movie starts that > many bytes earlier. Confirm `FWS`/`CWS`/`ZWS` at that offset. Decompress > `CWS` (zlib from byte 8) or `ZWS` (LZMA) to a flat `FWS`. Also dump the PE > stub and read its version resource -- it tells you which Flash player, and > therefore roughly which era. > > 2. **Tag walk.** Header, then `u16 (code << 6) | len` with the `0x3F` long > form. Two things to get right: after any bit-packed field such as `RECT` > you must advance to the next whole byte, not merely reset the bit pointer; > and `DefineSprite` contains a nested tag stream you must recurse into. > Verify by checking that the tag chain ends exactly on the declared file > length, and print a histogram of tag types before going further. > > 3. **Text.** All dialogue is likely static `DefineText`, which stores glyph > indices rather than characters, so a strings dump will find nothing. Read > `DefineFont` for the glyph count and `DefineFontInfo` for the glyph-index > to character-code table, then decode each `DefineText` record's bit-packed > glyph/advance pairs through it. This is the step that recovers the script. > > 4. **Images and sound.** `DefineBitsLossless/2` are zlib bitmaps -- handle > palette format 3 (note the row stride is padded to a multiple of 4) and > direct format 5 (ARGB, premultiplied when it has alpha). `DefineSound` > is raw PCM, MP3 (skip the 2-byte seek field) or Flash ADPCM. If ADPCM, > write the decoder: 2-bit code size selector, then 4096-sample blocks each > starting with a signed 16-bit sample and a 6-bit step index. Check that > decoded sample counts match the declared counts exactly. > > 5. **Shapes and display list.** Decode shape records into per-fill edge sets, > reversing edges referenced by the left-fill slot, chain into closed loops > and fill nonzero. Watch for mid-shape `StateNewStyles`, which restarts > style numbering against a newly appended style array. Then simulate the > display list per frame and render, remembering that movieclip playheads > run independently of the root timeline. > > 6. **Behavior.** Disassemble every `DoAction` and button handler. Report what > the movie actually does -- if it is all `stop()` and one `play()` button, > say so plainly rather than implying complexity that is not there. > > Then find what the movie never showed anyone. Diff defined character ids > against referenced ones; transform every placement into stage coordinates and > flag anything wholly outside the stage rect; build a per-asset ledger of > on-stage versus off-stage placements and call out any asset that was never > displayed at all; check for bytes after the last tag, trailing bytes after > each zlib stream, and any human-written strings; and compare embedded font > glyphs against glyphs actually used, since leftovers mean text was cut. > Finally, re-render every frame on an oversized canvas so the cropped-away > content is visible. > > Report the off-stage and never-displayed findings as a first-class result, > not a footnote. In files like this they are usually the most interesting > thing present. --- ## 12. The toolchain Python 3, plus Pillow and numpy. ffmpeg is optional and only used for the reconstruction video. Nothing else. ### 12.1 Layout and what gets produced Drop the projector in a folder with `tools/` beside it and run one command. Nothing is hard-coded -- the exe can have any name, and the folder can be anywhere: ``` / +-- YourMovie.exe the Flash projector you start with +-- tools/ this toolchain +-- extract/ created on the first run; everything lands here +-- YourMovie.swf the movie, playable in Ruffle +-- projector_stub.bin the Flash player the author shipped with | +-- images/ one PNG per bitmap | +-- bmp__x.png +-- sounds/ one file per DefineSound | +-- snd_.mp3 MP3 sounds, extracted as-is | +-- snd_.wav PCM and decoded-ADPCM sounds | +-- snd_.adpcm the raw ADPCM payload, kept alongside +-- shapes/ one SVG per DefineShape* | +-- shape_.svg | +-- _index.json bounds, fill/line counts, bitmap refs +-- frames/ the movie re-rendered, one PNG per frame | +-- frame_.png +-- _overscan/ the same frames on a padded canvas, so | +-- frame_.png off-stage content is visible | +-- timeline.txt frame-by-frame display list and sound cues +-- actionscript_disasm.txt every DoAction and button handler +-- text_dump.txt every text record, readable +-- text_records.json every text record, structured +-- assets_manifest.json bitmap and sound metadata +-- sprites.json movieclips and their bitmap sequences +-- _contact_sheet.png all bitmaps on one page ``` ``` python3 tools/run_all.py # everything python3 tools/run_all.py --quick # skip the two full-movie frame renders ``` The analysis scripts print their findings to stdout; redirect them if you want them on disk. Three more artifacts are per-movie rather than automatic, because they need decisions a parser cannot make -- where the chapters begin, how long a panel of dialogue should be held, which frames best illustrate a finding: ``` +-- SCRIPT.md the transcript (mkscript.py) +-- _concat.txt ffmpeg timing list (mkconcat.py) +-- _reconstruction.mp4 the movie as video (ffmpeg + _concat.txt) +-- _overscan/_hidden_cast.png the off-stage figure (compare.py) +-- viewer.html click-to-advance viewer (hand-written) ``` ### 12.2 Dependency order ``` swlpaths.py path resolution (no dependencies) unpack_projector.py exe -> stub + movie (swlpaths) swfparse.py header + recursive tag walker (no dependencies) timeline.py annotated display-list dump (swfparse) extract_assets.py bitmaps -> PNG, sounds -> MP3/WAV (swfparse) adpcm.py Flash ADPCM -> PCM WAV (standalone) textdump.py DefineText + fonts -> strings (swfparse) asdis.py ActionScript disassembler (swfparse) shapes.py DefineShape* -> SVG (swfparse) raster.py nonzero-winding scanline filler (numpy) render.py display-list replay -> PNG (swfparse, shapes, raster) sprites.py movieclip -> bitmap sequence map (swfparse) orphans.py unreferenced + off-stage scan (swfparse, shapes) hidden.py slack, zlib tails, strings, glyphs (swfparse, shapes) hidden2.py off-stage text scan (swfparse, shapes) hidden3.py per-bitmap visibility ledger (swfparse, shapes) sheet.py bitmap contact sheet (Pillow) mkscript.py SCRIPT.md (swfparse) * mkconcat.py ffmpeg timing list (swfparse) * compare.py the hidden-cast figure (Pillow) * run_all.py the driver build_readme.py this document ``` `*` marks the three scripts with movie-specific constants in them -- section boundaries, chosen frame numbers, panel timing. Everything else is generic. ### 12.3 `swlpaths.py` -- where things live ```python """Shared path resolution for the Flash-projector teardown toolchain. Layout this assumes -- drop the projector anywhere and put `tools/` beside it: / YourMovie.exe the Flash projector (or a bare .swf) tools/ these scripts extract/ created on first run; everything lands here Nothing else is hard-coded. Override the base folder with the SWL_ROOT environment variable if you want the output somewhere else. """ import os import sys import glob HERE = os.path.dirname(os.path.abspath(__file__)) if HERE not in sys.path: sys.path.insert(0, HERE) BASE = os.environ.get('SWL_ROOT') or os.path.dirname(HERE) OUT = os.path.join(BASE, 'extract') def _one(pattern, what): hits = sorted(glob.glob(os.path.join(BASE, pattern))) if not hits: return None if len(hits) > 1: sys.stderr.write('warning: %d %s found in %s, using %s\n' % (len(hits), what, BASE, os.path.basename(hits[0]))) return hits[0] EXE = _one('*.exe', 'executables') # The extracted movie is named after the projector; if the input was already a # bare .swf, use it where it sits. if EXE: STEM = os.path.splitext(os.path.basename(EXE))[0] SWF = os.path.join(OUT, STEM + '.swf') else: _swf = _one('*.swf', 'movies') STEM = os.path.splitext(os.path.basename(_swf))[0] if _swf else 'movie' SWF = _swf or os.path.join(OUT, 'movie.swf') def out(*parts): """Path inside the extract folder, creating parent directories.""" p = os.path.join(OUT, *parts) d = p if not os.path.splitext(p)[1] else os.path.dirname(p) os.makedirs(d, exist_ok=True) return p os.makedirs(OUT, exist_ok=True) ``` ### 12.4 `unpack_projector.py` -- the container ```python """Step 1: split a Flash projector .exe into the player stub and the movie. A projector is the stock Macromedia/Adobe player executable with the SWF appended and an 8-byte footer: [ PE executable: the Flash player stub ] [ SWF movie: FWS / CWS / ZWS ... ] [ 56 34 12 FA | u32 LE movie length ] <- magic 0xFA123456 Writes extract/.swf and extract/projector_stub.bin, and prints the PE header facts and version resource that identify which player was used. Also handles a bare .swf input (nothing to split) and CWS/ZWS compressed movies, which are decompressed to an equivalent FWS so every later script can assume a flat, uncompressed tag stream. """ import os import re import struct import sys import zlib import hashlib import datetime from swlpaths import BASE, OUT, EXE, SWF, STEM FOOTER = b'\x56\x34\x12\xFA' def find_movie(data): """Return (offset, length) of the appended SWF, or None.""" i = data.rfind(FOOTER) if i >= 0 and i + 8 <= len(data): size = struct.unpack_from(' len(data): continue ver = data[o + 3] ln = struct.unpack_from(' an equivalent uncompressed FWS.""" sig = movie[:3] if sig == b'FWS': return movie, 'uncompressed' head = b'FWS' + movie[3:8] if sig == b'CWS': body = zlib.decompress(movie[8:]) elif sig == b'ZWS': import lzma props = movie[12:17] dec = lzma.LZMADecompressor(format=lzma.FORMAT_RAW, filters=[lzma._decode_filter_properties(lzma.FILTER_LZMA1, props)]) body = dec.decompress(movie[17:]) else: raise ValueError('not a SWF: %r' % sig) out = head + body out = out[:4] + struct.pack('> 3 print(' SWF version %d, declared length %d, actual %d' % (ver, struct.unpack_from('>(7-s.bp))&1 v=(v<<1)|bit s.bp+=1 if s.bp==8: s.bp=0; s.o+=1 return v def sbits(s,n): v=s.bits(n) if n and (v>>(n-1))&1: v-= (1<>6; ln=th&0x3f if ln==0x3f: ln=r.u32() body=r.d[r.o:r.o+ln] tags.append(dict(off=off,code=code,name=TAGS.get(code,f'?{code}'),len=ln,depth=depth,parent=parent,body_off=r.o)) if code==39: # DefineSprite sr=R(r.d,r.o) cid=sr.u16(); fc=sr.u16() tags[-1]['id']=cid; tags[-1]['frames']=fc read_tags(sr, r.o+ln, depth+1, cid) r.o += ln if code==0 and depth==0: break read_tags(r, len(d), 0, None) return d, tags, dict(ver=ver,rect=rect,fps=fps,frames=frames) if __name__=='__main__': path=sys.argv[1] d,tags,hdr=parse(path) c=collections.Counter(t['name'] for t in tags) print("\n=== TAG HISTOGRAM ===") for k,v in c.most_common(): print(f"{v:6d} {k}") print("\ntotal tags", len(tags)) json.dump(tags, open(sys.argv[2],'w')) ``` ### 12.6 `timeline.py` -- the annotated display list ```python import os, struct, sys, json from swfparse import parse, R from swlpaths import OUT, SWF SWF = SWF d, tags, hdr = parse(SWF) # ---- id -> kind index ---- kind = {} for t in tags: n = t['name'] b = d[t['body_off']:t['body_off'] + t['len']] if n.startswith('Define') and n not in ('DefineFontInfo',): try: cid = struct.unpack_from(' text try: txt = {r['id']: r['text'] for r in json.load(open(os.path.join(OUT, "text_records.json")))} except Exception: txt = {} def matrix(r): r.align() sx = sy = 1.0 if r.bits(1): n = r.bits(5); sx = r.sbits(n) / 65536.0; sy = r.sbits(n) / 65536.0 if r.bits(1): n = r.bits(5); r.sbits(n); r.sbits(n) n = r.bits(5) tx = r.sbits(n); ty = r.sbits(n) r.align() return sx, sy, tx / 20.0, ty / 20.0 lines = [] frame = 1 sprite_frames = {} cur_depth = 0 for t in tags: n = t['name'] b = d[t['body_off']:t['body_off'] + t['len']] inspr = t['depth'] > 0 pre = (' sprite%s f%d ' % (t['parent'], sprite_frames.get(t['parent'], 1))) if inspr else ('FRAME %-4d ' % frame) if n == 'PlaceObject2': r = R(b, 0) fl = r.u8() depth = r.u16() cid = r.u16() if fl & 2 else None mtx = matrix(r) if fl & 4 else None extra = [] if fl & 8: extra.append('cxform') if fl & 16: extra.append('ratio') name = None # skip cxform to reach name if fl & 1: extra.append('MOVE') k = kind.get(cid, '?') if cid is not None else '' info = '' if cid in txt: info = ' TEXT=%r' % (txt[cid][:60]) pos = (' at (%.0f,%.0f) scale(%.2f,%.2f)' % (mtx[2], mtx[3], mtx[0], mtx[1])) if mtx else '' lines.append('%s place depth=%-4d id=%s %s%s%s %s' % (pre, depth, cid, k, pos, info, ' '.join(extra))) elif n == 'RemoveObject2': lines.append('%s remove depth=%d' % (pre, struct.unpack_from('>> StartSound id=%d flags=0x%02X' % (pre, sid, flags)) elif n == 'DoAction': lines.append('%s [action %d bytes]' % (pre, t['len'])) elif n == 'SetBackgroundColor': lines.append('%s bgcolor #%02X%02X%02X' % (pre, b[0], b[1], b[2])) elif n == 'DefineSprite': lines.append('%s DefineSprite id=%s frames=%s' % (pre, t.get('id'), t.get('frames'))) elif n == 'SoundStreamHead2': lines.append('%s SoundStreamHead2' % pre) elif n == 'ShowFrame': if inspr: sprite_frames[t['parent']] = sprite_frames.get(t['parent'], 1) + 1 else: lines.append('--- end frame %d ---' % frame) frame += 1 open(os.path.join(OUT, "timeline.txt"), 'w', encoding='utf-8').write('\n'.join(lines)) print('timeline lines', len(lines)) print('char id kinds:', json.dumps({str(k): v for k, v in sorted(kind.items())})) ``` ### 12.7 `extract_assets.py` -- bitmaps and sounds ```python import os, struct, zlib, sys, json, collections from swfparse import parse, R, TAGS from swlpaths import OUT, SWF d,tags,hdr=parse(SWF) os.makedirs(os.path.join(OUT, "images"),exist_ok=True) os.makedirs(os.path.join(OUT, "sounds"),exist_ok=True) def png(w,h,rgba): def chunk(t,data): c=t+data return struct.pack('>I',len(data))+c+struct.pack('>I',zlib.crc32(c)&0xffffffff) raw=b''.join(b'\x00'+rgba[y*w*4:(y+1)*w*4] for y in range(h)) return (b'\x89PNG\r\n\x1a\n'+chunk(b'IHDR',struct.pack('>IIBBBBB',w,h,8,6,0,0,0)) +chunk(b'IDAT',zlib.compress(raw,9))+chunk(b'IEND',b'')) manifest=[] for t in tags: if t['name'] in ('DefineBitsLossless','DefineBitsLossless2'): b=d[t['body_off']:t['body_off']+t['len']] cid=struct.unpack_from('H',row,x*2)[0] r_=((v>>10)&31)*255//31; g=((v>>5)&31)*255//31; bl=(v&31)*255//31 px+=bytes((r_,g,bl,255)) else: print("unk fmt",fmt); continue fn=os.path.join(OUT,"images",f"bmp_{cid:03d}_{w}x{h}.png") open(fn,'wb').write(png(w,h,bytes(px))) manifest.append(dict(kind='image',id=cid,w=w,h=h,fmt=fmt,alpha=alpha,file=os.path.basename(fn),tag_len=t['len'])) SR=[5512,11025,22050,44100] FMT={0:'uncompressed_ne',1:'ADPCM',2:'MP3',3:'uncompressed_le',6:'Nellymoser'} def wav(ch,sr,bits,data): ba=sr*ch*bits//8 return (b'RIFF'+struct.pack('>4)&0xf; rate=SR[(f>>2)&3]; size16=(f>>1)&1; stereo=f&1 nsamp=struct.unpack_from('>2)&3], codec=FMT.get((sf>>4)&0xf,(sf>>4)&0xf), rate=SR[(sf>>2)&3], bits=16 if (sf>>1)&1 else 8, channels=(sf&1)+1, samples_per_frame=struct.unpack_from('= len(s.d): raise EOFError v = (v << 1) | ((s.d[s.o] >> (7 - s.bp)) & 1) s.bp += 1 if s.bp == 8: s.bp = 0; s.o += 1 return v def sbits(s, n): v = s.bits(n) if v >> (n - 1): v -= 1 << n return v def decode(data, channels, nsamples): r = BR(data) nbits = r.bits(2) + 2 idxtab = INDEX_TABLE[nbits] out = [[] for _ in range(channels)] total = 0 try: while total < nsamples: samp = []; idx = [] for c in range(channels): samp.append(r.sbits(16)) idx.append(r.bits(6)) out[c].append(samp[c]) total += 1 for _ in range(4095): if total >= nsamples: break for c in range(channels): code = r.bits(nbits) step = STEP[idx[c]] sign = code >> (nbits - 1) mag = code & ((1 << (nbits - 1)) - 1) diff = step >> (nbits - 1) for b in range(nbits - 1): if mag & (1 << b): diff += step >> (nbits - 2 - b) samp[c] += -diff if sign else diff samp[c] = max(-32768, min(32767, samp[c])) idx[c] = max(0, min(88, idx[c] + idxtab[mag])) out[c].append(samp[c]) total += 1 except EOFError: pass return out def wav(ch, sr, chans): n = min(len(c) for c in chans) pcm = bytearray() for i in range(n): for c in chans: pcm += struct.pack('', os.path.basename(outp), 'samples', len(chans[0]), '/', m['samples']) ``` ### 12.9 `textdump.py` -- recovering the script ```python import os, struct, sys, json from swfparse import parse, R from swlpaths import OUT, SWF SWF = SWF d, tags, hdr = parse(SWF) fonts = {} # fontid -> {'nglyphs':n, 'name':..., 'map': [codes]} for t in tags: if t['name'] == 'DefineFont': b = d[t['body_off']:t['body_off'] + t['len']] fid = struct.unpack_from(' len(code): break ln = struct.unpack_from(' SVG path converter.""" import os, struct, sys, json, os from swfparse import parse, R from swlpaths import OUT, SWF def read_matrix(r): r.align() a = d_ = 1.0 b = c = 0.0 if r.bits(1): n = r.bits(5); a = r.sbits(n) / 65536.0; d_ = r.sbits(n) / 65536.0 if r.bits(1): n = r.bits(5); b = r.sbits(n) / 65536.0; c = r.sbits(n) / 65536.0 n = r.bits(5) tx = r.sbits(n); ty = r.sbits(n) r.align() return (a, b, c, d_, tx, ty) def read_fillstyles(r, ver): n = r.u8() if n == 0xFF and ver >= 2: n = r.u16() styles = [] for _ in range(n): t = r.u8() st = {'type': t} if t == 0x00: if ver >= 3: st['color'] = (r.u8(), r.u8(), r.u8(), r.u8()) else: st['color'] = (r.u8(), r.u8(), r.u8(), 255) elif t in (0x10, 0x12, 0x13): st['matrix'] = read_matrix(r) ng = r.u8() if t == 0x13: pass cnt = ng & 0x0F recs = [] for _ in range(cnt): ratio = r.u8() col = (r.u8(), r.u8(), r.u8(), r.u8() if ver >= 3 else 255) recs.append((ratio, col)) st['grad'] = recs if t == 0x13: r.u16() elif t in (0x40, 0x41, 0x42, 0x43): st['bitmap'] = r.u16() st['matrix'] = read_matrix(r) else: raise ValueError('bad fill type 0x%02X' % t) styles.append(st) return styles def read_linestyles(r, ver): n = r.u8() if n == 0xFF and ver >= 2: n = r.u16() out = [] for _ in range(n): w = r.u16() if ver >= 3: c = (r.u8(), r.u8(), r.u8(), r.u8()) else: c = (r.u8(), r.u8(), r.u8(), 255) out.append({'width': w, 'color': c}) return out def read_shape(r, ver, with_style=True): """Returns (fills, lines, edge_groups). Edge groups: list of (fillstyles, linestyles, subpaths).""" fills = read_fillstyles(r, ver) if with_style else [] lines = read_linestyles(r, ver) if with_style else [] r.align() nfb = r.bits(4); nlb = r.bits(4) x = y = 0 fs0 = fs1 = ls = 0 fbase = lbase = 0 # segments: list of dicts {fill0, fill1, line, pts:[('M',x,y),('L',x,y),('Q',cx,cy,x,y)]} segs = [] cur = None groups = [(fills, lines)] def flush(): nonlocal cur if cur and len(cur['pts']) > 1: segs.append(cur) cur = None while True: tf = r.bits(1) if tf == 0: flags = r.bits(5) if flags == 0: break new_styles = flags & 0x10 has_ls = flags & 0x08 has_f1 = flags & 0x04 has_f0 = flags & 0x02 has_mv = flags & 0x01 if has_mv: nb = r.bits(5) x = r.sbits(nb); y = r.sbits(nb) if has_f0: fs0 = r.bits(nfb) if has_f1: fs1 = r.bits(nfb) if has_ls: ls = r.bits(nlb) if new_styles and ver >= 2: flush() r.align() fbase = len(fills) lbase = len(lines) fills = fills + read_fillstyles(r, ver) lines = lines + read_linestyles(r, ver) r.align() nfb = r.bits(4); nlb = r.bits(4) fs0 = fs1 = ls = 0 flush() cur = {'f0': fs0 + fbase if fs0 else 0, 'f1': fs1 + fbase if fs1 else 0, 'ls': ls + lbase if ls else 0, 'pts': [('M', x, y)]} else: straight = r.bits(1) nb = r.bits(4) + 2 if straight: if r.bits(1): dx = r.sbits(nb); dy = r.sbits(nb) else: if r.bits(1): dx = 0; dy = r.sbits(nb) else: dx = r.sbits(nb); dy = 0 x += dx; y += dy if cur is None: cur = {'f0': fs0 + fbase if fs0 else 0, 'f1': fs1 + fbase if fs1 else 0, 'ls': ls + lbase if ls else 0, 'pts': [('M', x - dx, y - dy)]} cur['pts'].append(('L', x, y)) else: cdx = r.sbits(nb); cdy = r.sbits(nb) adx = r.sbits(nb); ady = r.sbits(nb) cx = x + cdx; cy = y + cdy x = cx + adx; y = cy + ady if cur is None: cur = {'f0': fs0 + fbase if fs0 else 0, 'f1': fs1 + fbase if fs1 else 0, 'ls': ls + lbase if ls else 0, 'pts': [('M', cx - cdx, cy - cdy)]} cur['pts'].append(('Q', cx, cy, x, y)) flush() r.align() return fills, lines, segs def rev(pts): """Reverse a segment's point list.""" # rebuild going backwards coords = [] x0, y0 = pts[0][1], pts[0][2] out = [] prev = (x0, y0) items = [] for p in pts[1:]: if p[0] == 'L': items.append(('L', prev, (p[1], p[2]))) prev = (p[1], p[2]) else: items.append(('Q', prev, (p[1], p[2]), (p[3], p[4]))) prev = (p[3], p[4]) items.reverse() if not items: return pts start = items[0][-1] res = [('M', start[0], start[1])] for it in items: if it[0] == 'L': res.append(('L', it[1][0], it[1][1])) else: res.append(('Q', it[2][0], it[2][1], it[1][0], it[1][1])) return res def chain(seglist): """Chain segments end-to-start into closed loops.""" remaining = list(seglist) paths = [] while remaining: cur = remaining.pop(0) pts = list(cur) changed = True while changed: changed = False endx, endy = (pts[-1][1], pts[-1][2]) if pts[-1][0] == 'L' or pts[-1][0] == 'M' else (pts[-1][3], pts[-1][4]) for i, s in enumerate(remaining): if abs(s[0][1] - endx) < 1e-6 and abs(s[0][2] - endy) < 1e-6: pts.extend(s[1:]) remaining.pop(i) changed = True break paths.append(pts) return paths def path_d(pts): out = [] for p in pts: if p[0] == 'M': out.append('M%g %g' % (p[1], p[2])) elif p[0] == 'L': out.append('L%g %g' % (p[1], p[2])) else: out.append('Q%g %g %g %g' % (p[1], p[2], p[3], p[4])) return ''.join(out) + 'Z' def col(c): return '#%02x%02x%02x' % (c[0], c[1], c[2]), c[3] / 255.0 def shape_to_svg_body(fills, lines, segs, bitmap_href=None): """Return list of svg element strings.""" els = [] byfill = {} for s in segs: for key, idx in (('f1', s['f1']), ('f0', s['f0'])): if idx: byfill.setdefault(idx, []).append(rev(s['pts']) if key == 'f0' else s['pts']) for idx in sorted(byfill): st = fills[idx - 1] if 0 < idx <= len(fills) else None if st is None: continue loops = chain(byfill[idx]) dstr = ' '.join(path_d(p) for p in loops) if st['type'] == 0: c, a = col(st['color']) els.append('' % (dstr, c, '' if a == 1 else ' fill-opacity="%g"' % a)) elif st['type'] in (0x40, 0x41, 0x42, 0x43): els.append('' % (dstr, st['bitmap'])) else: g = st.get('grad') or [(0, (128, 128, 128, 255))] c, a = col(g[0][1]) els.append('' % (dstr, c, a)) # strokes byline = {} for s in segs: if s['ls']: byline.setdefault(s['ls'], []).append(s['pts']) for idx in sorted(byline): st = lines[idx - 1] if 0 < idx <= len(lines) else None if st is None: continue c, a = col(st['color']) dstr = ' '.join(path_d(p)[:-1] for p in byline[idx]) els.append('' % (dstr, c, max(st['width'], 20), '' if a == 1 else ' stroke-opacity="%g"' % a)) return els def parse_shape_tag(d, t): ver = {'DefineShape': 1, 'DefineShape2': 2, 'DefineShape3': 3}[t['name']] b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) cid = r.u16() bounds = r.rect() fills, lines, segs = read_shape(r, ver) return cid, bounds, fills, lines, segs if __name__ == '__main__': SWF = SWF OUT = os.path.join(OUT, "shapes") os.makedirs(OUT, exist_ok=True) d, tags, hdr = parse(SWF) ok = err = 0 index = {} for t in tags: if t['name'] not in ('DefineShape', 'DefineShape2', 'DefineShape3'): continue try: cid, bounds, fills, lines, segs = parse_shape_tag(d, t) except Exception as e: err += 1 print('ERR shape @0x%X: %s' % (t['off'], e)) continue els = shape_to_svg_body(fills, lines, segs) x0, x1, y0, y1 = bounds w = max(x1 - x0, 1); h = max(y1 - y0, 1) defs = '' bmps = [f['bitmap'] for f in fills if f['type'] in (0x40, 0x41, 0x42, 0x43)] svg = ('%s%s' % (x0, y0, w, h, w / 20.0, h / 20.0, defs, ''.join(els))) open(os.path.join(OUT, 'shape_%03d.svg' % cid), 'w', encoding='utf-8').write(svg) index[cid] = dict(bounds_px=[round(v / 20.0, 1) for v in bounds], nfills=len(fills), nlines=len(lines), nsegs=len(segs), bitmaps=bmps) ok += 1 json.dump(index, open(os.path.join(OUT, '_index.json'), 'w'), indent=1) print('shapes ok', ok, 'err', err) ``` ### 12.12 `raster.py` -- the rasterizer ```python """Minimal nonzero-winding scanline rasterizer (numpy) for SWF shape paths.""" import numpy as np def flatten(pts, tol=3.0): """pts: [('M',x,y),('L',x,y),('Q',cx,cy,x,y)] -> list of (x,y) polygon points.""" out = [] cx = cy = 0.0 for p in pts: if p[0] == 'M': cx, cy = p[1], p[2] out.append((cx, cy)) elif p[0] == 'L': cx, cy = p[1], p[2] out.append((cx, cy)) else: x0, y0 = cx, cy x1, y1 = p[1], p[2] x2, y2 = p[3], p[4] n = max(3, int(((abs(x1 - x0) + abs(y1 - y0) + abs(x2 - x1) + abs(y2 - y1)) / max(tol, 1)) ** 0.5 * 2)) n = min(n, 24) for i in range(1, n + 1): t = i / n mt = 1 - t out.append((mt * mt * x0 + 2 * mt * t * x1 + t * t * x2, mt * mt * y0 + 2 * mt * t * y1 + t * t * y2)) cx, cy = x2, y2 return out class Canvas: def __init__(self, w, h, ss=3, bg=(255, 255, 255)): self.w = w; self.h = h; self.ss = ss self.W = w * ss; self.H = h * ss self.buf = np.zeros((self.H, self.W, 3), dtype=np.float32) self.buf[:, :] = bg def fill(self, loops, color, alpha=1.0, tex=None, texmat=None): """loops: list of point lists in *pixel* coords (already transformed).""" ss = self.ss edges = [] for lp in loops: n = len(lp) if n < 3: continue for i in range(n): x0, y0 = lp[i] x1, y1 = lp[(i + 1) % n] if y0 != y1: edges.append((x0 * ss, y0 * ss, x1 * ss, y1 * ss)) if not edges: return e = np.array(edges, dtype=np.float64) ymin = max(0, int(np.floor(min(e[:, 1].min(), e[:, 3].min())))) ymax = min(self.H - 1, int(np.ceil(max(e[:, 1].max(), e[:, 3].max())))) if ymax < ymin: return x0, y0, x1, y1 = e[:, 0], e[:, 1], e[:, 2], e[:, 3] dirs = np.where(y1 > y0, 1, -1) ylo = np.minimum(y0, y1); yhi = np.maximum(y0, y1) slope = (x1 - x0) / (y1 - y0) mask = np.zeros((self.H, self.W), dtype=bool) for yy in range(ymin, ymax + 1): yc = yy + 0.5 sel = (ylo <= yc) & (yhi > yc) if not sel.any(): continue xs = x0[sel] + (yc - y0[sel]) * slope[sel] ds = dirs[sel] order = np.argsort(xs) xs = xs[order]; ds = ds[order] wind = np.cumsum(ds) inside = wind != 0 row = mask[yy] for i in range(len(xs) - 1): if inside[i]: a = int(np.ceil(xs[i] - 0.5)); b = int(np.ceil(xs[i + 1] - 0.5)) a = max(a, 0); b = min(b, self.W) if b > a: row[a:b] = True if not mask.any(): return if tex is not None: ys, xs_ = np.nonzero(mask) a, b, c, dd, tx, ty = texmat det = a * dd - b * c if abs(det) < 1e-12: return px = xs_ / self.ss; py = ys / self.ss ux = px - tx; uy = py - ty u = (dd * ux - c * uy) / det v = (-b * ux + a * uy) / det th, tw = tex.shape[:2] ui = np.clip(u.astype(int), 0, tw - 1) vi = np.clip(v.astype(int), 0, th - 1) samp = tex[vi, ui] aa = samp[:, 3:4] / 255.0 * alpha self.buf[ys, xs_] = self.buf[ys, xs_] * (1 - aa) + samp[:, :3] * aa else: col = np.array(color, dtype=np.float32) if alpha >= 1.0: self.buf[mask] = col else: self.buf[mask] = self.buf[mask] * (1 - alpha) + col * alpha def stroke(self, loops, color, width_px, alpha=1.0): """Approximate stroking by drawing quads per segment.""" hw = max(width_px, 0.6) / 2.0 polys = [] for lp in loops: for i in range(len(lp) - 1): (ax, ay), (bx, by) = lp[i], lp[i + 1] dx, dy = bx - ax, by - ay L = (dx * dx + dy * dy) ** 0.5 if L < 1e-9: continue nx, ny = -dy / L * hw, dx / L * hw polys.append([(ax + nx, ay + ny), (bx + nx, by + ny), (bx - nx, by - ny), (ax - nx, ay - ny)]) for p in polys: self.fill([p], color, alpha) def image(self): from PIL import Image ss = self.ss b = self.buf.reshape(self.h, ss, self.w, ss, 3).mean(axis=(1, 3)) return Image.fromarray(np.clip(b, 0, 255).astype(np.uint8)) ``` ### 12.13 `render.py` -- replaying the display list ```python """Replay the root display list and render every main-timeline frame to PNG. python3 render.py all frames, exact stage size -> extract/frames/ python3 render.py 5,45,212 just those frames python3 render.py --overscan oversized canvas -> extract/_overscan/ python3 render.py --overscan 45 both --overscan pads the canvas by OVERSCAN px on each axis and shifts the whole root display list, so art the stage rect would crop away stays visible. That is how off-stage parked content is found. """ import os, struct, sys, json, zlib import numpy as np from PIL import Image from swfparse import parse, R from shapes import read_shape, read_matrix, parse_shape_tag from raster import Canvas, flatten from swlpaths import OUT, SWF OVERSCAN_X, OVERSCAN_Y = 100, 40 overscan = '--overscan' in sys.argv args = [a for a in sys.argv[1:] if not a.startswith('--')] d, tags, hdr = parse(SWF) x0, x1, y0, y1 = hdr['rect'] SW, SH = int(round((x1 - x0) / 20.0)), int(round((y1 - y0) / 20.0)) PAD_X, PAD_Y = (OVERSCAN_X, OVERSCAN_Y) if overscan else (0, 0) STAGE_W, STAGE_H = SW + 2 * PAD_X, SH + 2 * PAD_Y PADM = (1.0, 0.0, 0.0, 1.0, PAD_X * 20 - x0, PAD_Y * 20 - y0) FRAMEDIR = os.path.join(OUT, '_overscan' if overscan else 'frames') os.makedirs(FRAMEDIR, exist_ok=True) print('stage %dx%d px, rendering %dx%d%s -> %s' % (SW, SH, STAGE_W, STAGE_H, ' (overscan)' if overscan else '', FRAMEDIR)) # ---------- bitmaps ---------- bitmaps = {} IMGDIR = os.path.join(OUT, "images") for f in os.listdir(IMGDIR): if f.startswith('bmp_'): cid = int(f.split('_')[1]) bitmaps[cid] = np.array(Image.open(os.path.join(IMGDIR, f)).convert('RGBA')) # ---------- shapes ---------- shapes = {} for t in tags: if t['name'] in ('DefineShape', 'DefineShape2', 'DefineShape3'): cid, bounds, fills, lines, segs = parse_shape_tag(d, t) shapes[cid] = (fills, lines, segs) # ---------- fonts ---------- fonts = {} for t in tags: if t['name'] == 'DefineFont': b = d[t['body_off']:t['body_off'] + t['len']] fid = struct.unpack_from(' 0 and t['parent'] in sprite_tags: sprite_tags[t['parent']]['tags'].append(t) def mul(m1, m2): a1, b1, c1, d1, tx1, ty1 = m1 a2, b2, c2, d2, tx2, ty2 = m2 return (a1 * a2 + c1 * b2, b1 * a2 + d1 * b2, a1 * c2 + c1 * d2, b1 * c2 + d1 * d2, a1 * tx2 + c1 * ty2 + tx1, b1 * tx2 + d1 * ty2 + ty1) def apply(m, x, y): a, b, c, dd, tx, ty = m return (a * x + c * y + tx, b * x + dd * y + ty) IDENT = (1.0, 0.0, 0.0, 1.0, 0.0, 0.0) TW = 1.0 / 20.0 def draw_shape(cv, cid, m, cx=None): ent = shapes.get(cid) if not ent: return fills, lines, segs = ent byfill = {} from shapes import rev, chain for s in segs: for key, idx in (('f1', s['f1']), ('f0', s['f0'])): if idx: byfill.setdefault(idx, []).append(rev(s['pts']) if key == 'f0' else s['pts']) for idx in sorted(byfill): if not (0 < idx <= len(fills)): continue st = fills[idx - 1] loops = chain(byfill[idx]) polys = [] for lp in loops: pl = flatten(lp) polys.append([apply(m, px, py) for px, py in pl]) polys = [[(x * TW, y * TW) for x, y in p] for p in polys] if st['type'] == 0: c = st['color'] cv.fill(polys, (c[0], c[1], c[2]), c[3] / 255.0) elif st['type'] in (0x40, 0x41, 0x42, 0x43): tex = bitmaps.get(st['bitmap']) if tex is None: continue tm = mul(m, st['matrix']) tm = tuple(v * TW for v in tm) cv.fill(polys, None, 1.0, tex=tex, texmat=tm) else: g = st.get('grad') or [(0, (128, 128, 128, 255))] c = g[-1][1] cv.fill(polys, (c[0], c[1], c[2]), c[3] / 255.0) byline = {} for s in segs: if s['ls'] and 0 < s['ls'] <= len(lines): byline.setdefault(s['ls'], []).append(s['pts']) for idx in sorted(byline): st = lines[idx - 1] c = st['color'] sc = (abs(m[0]) + abs(m[3])) / 2.0 for lp in byline[idx]: pl = [apply(m, px, py) for px, py in flatten(lp)] pl = [(x * TW, y * TW) for x, y in pl] pass # stroke pass disabled: quad-approx produced artifacts; fills carry the outlines def draw_text(cv, cid, m): ent = texts.get(cid) if not ent: return from shapes import rev, chain tm = mul(m, ent['matrix']) for rec in ent['recs']: f = fonts.get(rec['font']) if not f or rec['size'] is None: continue scale = rec['size'] / 1024.0 x = rec['x'] c = rec['color'] for gi, adv in rec['glyphs']: if gi < len(f['glyphs']): segs = f['glyphs'][gi] loops = chain([s['pts'] for s in segs if s['f1'] or s['f0']] or [s['pts'] for s in segs]) polys = [] for lp in loops: pl = flatten(lp, tol=8) pts = [apply(tm, x + px * scale, rec['y'] + py * scale) for px, py in pl] polys.append([(px * TW, py * TW) for px, py in pts]) if polys: cv.fill(polys, (c[0], c[1], c[2]), c[3] / 255.0) x += adv def render_sprite(cv, sid, m, playhead): sp = sprite_tags.get(sid) if not sp: return frames = sp['frames'] target = (playhead % frames) + 1 if frames else 1 disp = {} f = 1 for t in sp['tags']: if t['name'] == 'ShowFrame': if f == target: break f += 1 elif t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None mm = read_matrix(r) if fl & 4 else IDENT if fl & 1 and depth in disp: old = disp[depth] cid = cid if cid is not None else old[0] mm = mm if fl & 4 else old[1] disp[depth] = (cid, mm) elif t['name'] == 'RemoveObject2': dp = struct.unpack_from(' 0: continue n = t['name'] b = d[t['body_off']:t['body_off'] + t['len']] if n == 'SetBackgroundColor': bg = (b[0], b[1], b[2]) elif n == 'PlaceObject2': r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None mm = read_matrix(r) if fl & 4 else None old = disp.get(depth) if fl & 1 and old: cid = cid if cid is not None else old[0] mm = mm if mm else old[1] disp[depth] = (cid, mm or IDENT) placed_at[depth] = frame elif n == 'RemoveObject2': dp = struct.unpack_from(' bitmap id (fillstyle type 0x41/0x40 clipped bitmap fill) shape_bitmap = {} for t in tags: if t['name'] in ('DefineShape', 'DefineShape2', 'DefineShape3'): b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) cid = r.u16() r.rect() n = r.u8() if n == 0xFF: n = r.u16() for _ in range(n): ftype = r.u8() if ftype == 0: if t['name'] == 'DefineShape3': r.o += 4 else: r.o += 3 elif ftype in (0x10, 0x12): # gradient - bail shape_bitmap[cid] = 'gradient' break elif ftype in (0x40, 0x41, 0x42, 0x43): bid = r.u16() shape_bitmap[cid] = bid break else: break sprites = {} cur = None frame = 1 for t in tags: if t['name'] == 'DefineSprite': cur = t['id'] sprites[cur] = dict(frames=t['frames'], content=[]) frame = 1 if t['depth'] > 0 and cur is not None: if t['name'] == 'ShowFrame': frame += 1 elif t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None sprites[t['parent']]['content'].append(dict(frame=frame, depth=depth, id=cid, bitmap=shape_bitmap.get(cid))) elif t['name'] == 'RemoveObject2': sprites[t['parent']]['content'].append(dict(frame=frame, remove=struct.unpack_from('bitmap map:', json.dumps({str(k): v for k, v in sorted(shape_bitmap.items()) if v != 'gradient'})) json.dump(dict(sprites=sprites, shape_bitmap={str(k): v for k, v in shape_bitmap.items()}), open(os.path.join(OUT, "sprites.json"), 'w'), indent=1) ``` ### 12.15 `orphans.py` -- unreferenced and off-stage ```python import os, struct, sys, json, zlib, os from swfparse import parse, R from shapes import read_matrix, parse_shape_tag from swlpaths import OUT, SWF E = OUT d, tags, hdr = parse(SWF) defined = {} for t in tags: n = t['name'] if n.startswith('Define') and n not in ('DefineFontInfo',): cid = struct.unpack_from(' list of reasons def ref(cid, why): referenced.setdefault(cid, []).append(why) # 1. PlaceObject2, root + sprites frame = 1 placements = [] for t in tags: if t['name'] == 'ShowFrame' and t['depth'] == 0: frame += 1 if t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None m = read_matrix(r) if fl & 4 else None alpha = None if fl & 8: r.align() has_add = r.bits(1); has_mul = r.bits(1); nb = r.bits(4) mul = [r.sbits(nb) for _ in range(4)] if has_mul else None add = [r.sbits(nb) for _ in range(4)] if has_add else None r.align() if mul: alpha = mul[3] / 256.0 if cid is not None: ref(cid, 'placed f%d depth%d%s' % (frame, depth, '' if t['depth'] == 0 else ' (in sprite %s)' % t['parent'])) placements.append(dict(frame=frame, depth=depth, cid=cid, m=m, alpha=alpha, root=(t['depth'] == 0))) # 2. bitmap fills inside shapes for t in tags: if t['name'].startswith('DefineShape'): cid, bounds, fills, lines, segs = parse_shape_tag(d, t) for f in fills: if f['type'] in (0x40, 0x41, 0x42, 0x43) and f['bitmap'] != 0xFFFF: ref(f['bitmap'], 'bitmap fill in shape %d' % cid) # 3. DefineText -> font for t in tags: if t['name'] in ('DefineText', 'DefineText2'): b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0); r.u16(); r.rect(); read_matrix(r) ng = r.u8(); na = r.u8() while True: flags = r.u8() if flags == 0: break if flags & 0x80: if flags & 8: ref(r.u16(), 'font used by text') if flags & 4: r.o += 3 if flags & 1: r.s16() if flags & 2: r.s16() if flags & 8: r.u16() n = r.u8(); r.align() for _ in range(n): r.bits(ng); r.sbits(na) r.align() # 4. button states for t in tags: if t['name'] == 'DefineButton2': b = d[t['body_off']:t['body_off'] + t['len']] i = 5 while i < len(b) and b[i] != 0: st = b[i]; i += 1 chid = struct.unpack_from(' 550 or x1 < 0 or y0 > 335 or y1 < 0: off.append((p['frame'], p['depth'], p['cid'], round(x0), round(x1), round(y0), round(y1))) for o in off[:40]: print(' f%-4d depth%-3d id%-4d x %d..%d y %d..%d' % o) print(' total fully off-stage placements:', len(off)) print() print('--- placements with alpha multiplier < 1 (faded/invisible) ---') fa = [p for p in placements if p['alpha'] is not None and p['alpha'] < 0.99] for p in fa[:20]: print(' f%d depth%d id%s alpha=%.2f' % (p['frame'], p['depth'], p['cid'], p['alpha'])) print(' count:', len(fa)) ``` ### 12.16 `hidden.py` -- slack, zlib tails, strings, unused glyphs ```python import os, struct, sys, json, zlib, re, collections from swfparse import parse, R from shapes import read_matrix, parse_shape_tag from swlpaths import OUT, SWF, EXE E = OUT d, tags, hdr = parse(SWF) exe = open(EXE, 'rb').read() if EXE else b'' # locate the appended movie the same way unpack_projector does FOOTER = b'\x56\x34\x12\xFA' _i = exe.rfind(FOOTER) MOVIE_OFF = (_i - struct.unpack_from('= 0 else 0 MOVIE_LEN = struct.unpack_from('= 0 else 0 stub = exe[:MOVIE_OFF] print('=' * 72) print('A. BYTE-LEVEL: gaps, trailing data, container slack') print('=' * 72) # tag coverage of the root stream end = 0 last = None for t in tags: if t['depth'] == 0: end = t['body_off'] + t['len'] last = t print('last root tag: %s @0x%X ends at %d; file length %d -> slack %d bytes' % (last['name'], last['off'], end, len(d), len(d) - end)) if exe: print('bytes in exe after the movie: %d (8 = just the projector footer)' % (len(exe) - MOVIE_OFF - MOVIE_LEN)) # check every zlib stream in bitmaps for trailing bytes extra = 0 for t in tags: if t['name'] in ('DefineBitsLossless', 'DefineBitsLossless2'): b = d[t['body_off']:t['body_off'] + t['len']] fmt = b[2] o = 8 if fmt == 3 else 7 dec = zlib.decompressobj() dec.decompress(b[o:]) if dec.unused_data: print(' bitmap id %d has %d unused bytes after the zlib stream' % (struct.unpack_from('> 4) & 0xF for tag in ENC: i = b.find(tag) if i >= 0: txt = b[i:i + 24].decode('latin-1') txt = ''.join(c if 32 <= ord(c) < 127 else '.' for c in txt) print(' sound id %-4d codec %d %s' % (sid, codec, txt)) found_enc = True break if not found_enc: print(' none') print() print('=' * 72) print('B. STRINGS: author paths, tool fingerprints, anything human-written') print('=' * 72) pats = [rb'[A-Za-z]:\\[ -~]{4,80}', rb'[ -~]{4,60}\.fla\b', rb'[ -~]{4,60}\.swf\b', rb'Macromedia[ -~]{0,40}', rb'Generator[ -~]{0,30}', rb'[ -~]{0,20}Toops[ -~]{0,20}', rb'[ -~]{0,20}Overn[ -~]{0,20}', rb'http[ -~]{4,80}', rb'[ -~]{0,15}@[ -~]{4,40}'] for label, blob in (('SWF', d), ('PE stub', stub)): seen = set() for p in pats: for m in re.finditer(p, blob): s = m.group().decode('latin-1') if s not in seen: seen.add(s) if label == 'SWF': print(' SWF hits:', sorted(seen) if seen else 'NONE') else: # stub is stock; only show non-boilerplate interesting = [s for s in seen if not any(k in s for k in ('Macromedia', 'SwFlsh', 'Flash', 'http://www.macromedia', 'Shockwave'))] print(' PE stub non-boilerplate hits:', sorted(interesting)[:20] if interesting else 'NONE') print() print('=' * 72) print('C. FONT GLYPHS EMBEDDED BUT NEVER TYPED') print('=' * 72) fonts = {} for t in tags: if t['name'] == 'DefineFontInfo': b = d[t['body_off']:t['body_off'] + t['len']] fid = struct.unpack_from(' %s' % (fid, len(codes), len(unused), ''.join(c for _, c in unused) or '(none)')) print() print('=' * 72) print('D. ART PLACED OUTSIDE THE 550x335 STAGE (the off-stage parking lot)') print('=' * 72) STAGE = (0, 11000, 0, 6700) def outside(x0, x1, y0, y1): return x0 > STAGE[1] or x1 < STAGE[0] or y0 > STAGE[3] or y1 < STAGE[2] # bitmap-fill rectangles inside every shape, in shape-local coords hidden_fills = collections.Counter() hidden_detail = collections.defaultdict(set) for t in tags: if not t['name'].startswith('DefineShape'): continue cid, bounds, fills, lines, segs = parse_shape_tag(d, t) for s in segs: idx = s['f1'] or s['f0'] if not (0 < idx <= len(fills)): continue st = fills[idx - 1] if st['type'] not in (0x40, 0x41, 0x42, 0x43) or st['bitmap'] == 0xFFFF: continue xs = [p[1] for p in s['pts']] + [p[3] for p in s['pts'] if p[0] == 'Q'] ys = [p[2] for p in s['pts']] + [p[4] for p in s['pts'] if p[0] == 'Q'] if xs and outside(min(xs), max(xs), min(ys), max(ys)): hidden_fills[st['bitmap']] += 1 hidden_detail[st['bitmap']].add(cid) print(' bitmap-fill rectangles authored entirely outside the stage:') for bid, n in hidden_fills.most_common(): print(' bitmap %-4d used %3d times off-stage, in %d different shapes' % (bid, n, len(hidden_detail[bid]))) print(' total off-stage bitmap placements inside composite shapes:', sum(hidden_fills.values())) ``` ### 12.17 `hidden2.py` -- off-stage text, and where the losers get parked ```python import os, struct, sys, json, os, collections from swfparse import parse, R from shapes import read_matrix, parse_shape_tag from swlpaths import OUT, SWF E = OUT d, tags, hdr = parse(SWF) recs = {r['id']: r for r in json.load(open(os.path.join(E, "text_records.json")))} # text bounds in twips tb = {} for t in tags: if t['name'] in ('DefineText', 'DefineText2'): b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) cid = r.u16() tb[cid] = r.rect() print('=' * 72) print('E. TEXT placed outside / partly outside the visible stage') print('=' * 72) frame = 1 found = 0 for t in tags: if t['name'] == 'ShowFrame' and t['depth'] == 0: frame += 1 if t['name'] != 'PlaceObject2': continue b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None m = read_matrix(r) if fl & 4 else (1, 0, 0, 1, 0, 0) if cid not in tb: continue bb = tb[cid] a, b_, c, dd, tx, ty = m xs = [a * bb[i] + c * bb[j] + tx for i in (0, 1) for j in (2, 3)] ys = [b_ * bb[i] + dd * bb[j] + ty for i in (0, 1) for j in (2, 3)] x0, x1, y0, y1 = min(xs) / 20, max(xs) / 20, min(ys) / 20, max(ys) / 20 if x0 < -1 or x1 > 551 or y0 < -1 or y1 > 336: found += 1 clipped = 'FULLY OFF-STAGE' if (x0 > 550 or x1 < 0 or y0 > 335 or y1 < 0) else 'partly clipped' print(' f%-4d id%-4d %-16s x %.0f..%.0f y %.0f..%.0f %r' % (frame, cid, clipped, x0, x1, y0, y1, recs[cid]['text'][:60])) if not found: print(' none - every text object sits fully inside the visible stage') print() print('=' * 72) print('F. WHERE the eliminated contestants get parked (bitmaps 1,2,3,5)') print('=' * 72) STAGEX = (0, 11000); STAGEY = (0, 6700) loc = collections.defaultdict(list) for t in tags: if not t['name'].startswith('DefineShape'): continue cid, bounds, fills, lines, segs = parse_shape_tag(d, t) for s in segs: idx = s['f1'] or s['f0'] if not (0 < idx <= len(fills)): continue st = fills[idx - 1] if st['type'] not in (0x40, 0x41, 0x42, 0x43) or st['bitmap'] == 0xFFFF: continue xs = [p[1] for p in s['pts']] + [p[3] for p in s['pts'] if p[0] == 'Q'] ys = [p[2] for p in s['pts']] + [p[4] for p in s['pts'] if p[0] == 'Q'] if not xs: continue x0, x1, y0, y1 = min(xs), max(xs), min(ys), max(ys) if x0 > STAGEX[1] or x1 < STAGEX[0] or y0 > STAGEY[1] or y1 < STAGEY[0]: loc[st['bitmap']].append((cid, round(x0 / 20), round(x1 / 20), round(y0 / 20), round(y1 / 20))) NAMES = {1: 'Kain', 2: 'Bartz', 3: 'Cecil', 4: 'Edgar FFVI', 5: 'Edward FFIV', 7: 'Terra', 29: 'Kefka', 66: 'Kefka', 68: 'Kefka', 171: 'Edgar', 212: 'Edgar', 239: 'Edgar'} # frame each shape is first placed on first = {} frame = 1 for t in tags: if t['name'] == 'ShowFrame' and t['depth'] == 0: frame += 1 if t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] fl = b[0] if fl & 2: cid = struct.unpack_from(' list of (bitmap, local rect) shape_bmp = collections.defaultdict(list) for t in tags: if not t['name'].startswith('DefineShape'): continue cid, bounds, fills, lines, segs = parse_shape_tag(d, t) for s in segs: idx = s['f1'] or s['f0'] if not (0 < idx <= len(fills)): continue st = fills[idx - 1] if st['type'] not in (0x40, 0x41, 0x42, 0x43) or st['bitmap'] == 0xFFFF: continue xs = [p[1] for p in s['pts']] + [p[3] for p in s['pts'] if p[0] == 'Q'] ys = [p[2] for p in s['pts']] + [p[4] for p in s['pts'] if p[0] == 'Q'] if xs: shape_bmp[cid].append((st['bitmap'], min(xs), max(xs), min(ys), max(ys))) # sprite -> shapes it contains sprite_shapes = collections.defaultdict(list) for t in tags: if t['depth'] > 0 and t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] fl = b[0] if fl & 2: sprite_shapes[t['parent']].append(struct.unpack_from(' 0 or t['name'] != 'PlaceObject2': continue b = d[t['body_off']:t['body_off'] + t['len']] r = R(b, 0) fl = r.u8(); depth = r.u16() cid = r.u16() if fl & 2 else None m = read_matrix(r) if fl & 4 else (1.0, 0.0, 0.0, 1.0, 0, 0) if cid is None: continue targets = [cid] + sprite_shapes.get(cid, []) for tc in targets: for (bid, x0, x1, y0, y1) in shape_bmp.get(tc, []): a, b_, c, dd, tx, ty = m X = [a * x + c * y + tx for x in (x0, x1) for y in (y0, y1)] Y = [b_ * x + dd * y + ty for x in (x0, x1) for y in (y0, y1)] gx0, gx1, gy0, gy1 = min(X), max(X), min(Y), max(Y) if gx0 > 11000 or gx1 < 0 or gy0 > 6700 or gy1 < 0: offstage[bid] += 1 else: onstage[bid] += 1 NAMES = {1: 'Kain', 2: 'Bartz', 3: 'Cecil', 4: 'Edgar (FFVI)', 5: 'Edward (FFIV)', 6: 'Crono', 7: 'Terra', 8: 'Black Mage', 9: 'backdrop', 11: 'Kefka', 13: 'Kefka', 29: 'Kefka', 66: 'Kefka', 68: 'Kefka', 79: 'Kefka', 81: 'Kefka', 171: 'Edgar', 212: 'Edgar', 227: 'Edgar', 229: 'Edgar', 239: 'Edgar'} allb = sorted(set(onstage) | set(offstage)) print('=' * 78) print('G. PER-BITMAP: ever actually visible on the 550x335 stage?') print('=' * 78) print('%-6s %-14s %10s %10s %s' % ('bmp', 'who', 'on-stage', 'off-stage', 'verdict')) never = [] for bid in allb: on, off = onstage[bid], offstage[bid] v = 'VISIBLE' if on else '*** NEVER VISIBLE ***' if not on: never.append(bid) print('%-6d %-14s %10d %10d %s' % (bid, NAMES.get(bid, '?'), on, off, v)) print() print('bitmaps that NEVER appear inside the visible stage:', never) ``` ### 12.19 `sheet.py` -- the contact sheet ```python from PIL import Image, ImageDraw import glob, os from swlpaths import OUT D = os.path.join(OUT, "images") files = sorted(glob.glob(os.path.join(D, '*.png'))) SC = 4 cols = 7 cw, ch = 200, 220 sheet = Image.new('RGBA', (cols * cw, ((len(files) + cols - 1) // cols) * ch), (255, 0, 255, 255)) dr = ImageDraw.Draw(sheet) for i, f in enumerate(files): im = Image.open(f).convert('RGBA') s = min(180 / im.width, 180 / im.height, 8) im2 = im.resize((int(im.width * s), int(im.height * s)), Image.NEAREST) x = (i % cols) * cw + (190 - im2.width) // 2 y = (i // cols) * ch + 30 bg = Image.new('RGBA', im2.size, (255, 255, 255, 255)) bg.alpha_composite(im2) sheet.paste(bg, (x, y)) dr.text(((i % cols) * cw + 5, (i // cols) * ch + 8), os.path.basename(f)[:26], fill=(0, 0, 0, 255)) out = os.path.join(OUT, "_contact_sheet.png") sheet.convert('RGB').save(out) print(out, sheet.size) ``` ### 12.20 `mkscript.py` -- the transcript ```python import json, os, sys, struct from swfparse import parse from swlpaths import OUT, SWF E = OUT d, tags, hdr = parse(SWF) recs = {r['id']: r for r in json.load(open(os.path.join(E, "text_records.json")))} # walk main timeline, record the frame each text character is PLACED on placed = {} stops = {} frame = 1 for t in tags: if t['depth'] > 0: continue if t['name'] == 'ShowFrame': frame += 1 elif t['name'] == 'DoAction': b = d[t['body_off']:t['body_off'] + t['len']] stops[frame] = 'stopSounds' if b[0] == 0x09 else 'stop' elif t['name'] == 'PlaceObject2': b = d[t['body_off']:t['body_off'] + t['len']] fl = b[0] cid = struct.unpack_from(' 0: continue if t['name'] == 'ShowFrame': frame += 1 elif t['name'] == 'DoAction': stops.add(frame) SEP = chr(92) lines = [] total = 0.0 for f in range(1, 274): p = os.path.join(E, 'frames', 'frame_%03d.png' % f).replace(SEP, '/') if f in stops: n = sum(len(t) for t in tf.get(f, [])) dur = max(1.6, min(7.0, 1.4 + n * 0.055)) else: dur = 1.0 / 12 lines.append("file '%s'" % p) lines.append("duration %.4f" % dur) total += dur lines.append("file '%s'" % os.path.join(E, 'frames', 'frame_273.png').replace(SEP, '/')) open(os.path.join(E, '_concat.txt'), 'w', encoding='utf-8').write('\n'.join(lines)) print('total runtime %.1f s (%.1f min)' % (total, total / 60)) ``` ### 12.22 `compare.py` -- the hidden-cast figure ```python from PIL import Image, ImageDraw import os from swlpaths import OUT E = OUT picks = [(150, 'f150 - Cecil and Bartz have just been eliminated'), (215, 'f215 - final round; four of the eight are parked off-frame'), (272, 'f272 - "THE END." card, with the cast still standing outside it')] W, H = 760, 420 sheet = Image.new('RGB', (W + 20, len(picks) * (H + 26) + 10), (18, 18, 18)) dr = ImageDraw.Draw(sheet) for i, (f, cap) in enumerate(picks): p = os.path.join(E, '_overscan', 'frame_%03d.png' % f) if not os.path.exists(p): continue im = Image.open(p).convert('RGB') # dim everything outside the real stage so the hidden art reads clearly mask = Image.new('L', im.size, 255) ImageDraw.Draw(mask).rectangle([100, 40, 649, 374], fill=0) outside = Image.blend(im, Image.new('RGB', im.size, (25, 10, 12)), 0.45) im = Image.composite(outside, im, mask) ImageDraw.Draw(im).rectangle([100, 40, 649, 374], outline=(0, 255, 90), width=2) y = i * (H + 26) + 24 sheet.paste(im, (10, y)) dr.text((12, y - 18), cap + ' -- green box = the 550x335 the viewer sees; dimmed = never rendered', fill=(255, 235, 120)) out = os.path.join(E, '_overscan', '_hidden_cast.png') sheet.save(out) print(out) ``` ### 12.23 `run_all.py` -- the driver ```python """Run the whole teardown pipeline in order. python3 tools/run_all.py everything python3 tools/run_all.py --quick skip the two full-movie frame renders Put the projector .exe (or a bare .swf) in the folder above `tools/` and run this. Everything lands in `extract/` next to it. Needs Python 3 and Pillow + numpy. ffmpeg is optional and only used for the reconstruction video. """ import os import subprocess import sys import time from swlpaths import BASE, OUT, EXE, SWF HERE = os.path.dirname(os.path.abspath(__file__)) PY = sys.executable QUICK = '--quick' in sys.argv STEPS = [ ('unpack_projector.py', [], 'split the .exe into player stub + movie'), ('timeline.py', [], 'frame-by-frame display list and sound cues'), ('extract_assets.py', [], 'bitmaps -> PNG, sounds -> MP3/WAV'), ('adpcm.py', [], 'decode the Flash-ADPCM sounds to WAV'), ('textdump.py', [], 'DefineText + font tables -> real strings'), ('asdis.py', [], 'disassemble every ActionScript block'), ('shapes.py', [], 'DefineShape* -> SVG'), ('sprites.py', [], 'movieclip -> bitmap sequence map'), ('render.py', [], 'render every frame at stage size'), ('render.py', ['--overscan'], 'render every frame with overscan'), ('sheet.py', [], 'bitmap contact sheet'), ('orphans.py', [], 'unreferenced characters + off-stage placements'), ('hidden.py', [], 'container slack, zlib tails, strings, unused glyphs'), ('hidden2.py', [], 'off-stage text scan'), ('hidden3.py', [], 'per-bitmap "was this ever visible?" table'), ] SKIP_IF_QUICK = {'render.py'} def main(): if EXE is None and not os.path.exists(SWF): sys.exit('Put a Flash projector .exe (or a .swf) in %s first.' % BASE) print('base %s' % BASE) print('output %s' % OUT) print() log = [] for script, extra, what in STEPS: if QUICK and script in SKIP_IF_QUICK: print('-- skipping %s %s (--quick)' % (script, ' '.join(extra))) continue label = ' '.join([script] + extra) print('=' * 70) print('>> %-32s %s' % (label, what)) print('=' * 70) t0 = time.time() r = subprocess.run([PY, os.path.join(HERE, script)] + extra, cwd=HERE) dt = time.time() - t0 log.append((label, r.returncode, dt)) if r.returncode != 0: print('!! %s exited %d' % (label, r.returncode)) print() print('=' * 70) print('SUMMARY') for label, rc, dt in log: print(' %-28s %-4s %6.1fs' % (label, 'ok' if rc == 0 else 'FAIL', dt)) print() print('Movie-specific extras (edit the frame lists inside them first):') print(' mkscript.py build SCRIPT.md -- section boundaries are per-movie') print(' mkconcat.py ffmpeg timing list for the reconstruction video') print(' compare.py the "hidden cast" figure') if __name__ == '__main__': main() ``` ### 12.24 `build_readme.py` -- this document ```python """Assemble the standalone teardown document. `readme_src.md` is the prose. Two markers get expanded on the way out: the current contents of tools/name.py, fenced the current contents of extract/name.md, inline so the published document can never drift from the scripts that produced its findings, or from the generated transcript. Usage: python3 tools/build_readme.py [output.md] [--txt] """ import os import re import sys HERE = os.path.dirname(os.path.abspath(__file__)) BASE = os.path.dirname(HERE) SRC = os.path.join(HERE, 'readme_src.md') DEFAULT_OUT = os.path.join(BASE, 'Squares-Weakest-Link-teardown.md') # Spellings to normalize on the way out. Quoted material from the binary is # protected by KEEP below so original strings stay verbatim. US = [ ('color', 'color'), ('Color', 'Color'), ('behavior', 'behavior'), ('Behavior', 'Behavior'), ('artifact', 'artifact'), ('Artifact', 'Artifact'), ('organized', 'organized'), ('recognized', 'recognized'), ('center', 'center'), ('Center', 'Center'), ('labeled', 'labeled'), ('Labeled', 'Labeled'), ('analyze', 'analyze'), ('catalog', 'catalog'), ('honor', 'honor'), ('favor', 'favor'), ('defense', 'defense'), ('while', 'while'), ('among', 'among'), ('gray', 'gray'), ('Gray', 'Gray'), ('practice', 'practice'), ('traveled', 'traveled'), ] # Quoted from the movie or from Macromedia's own strings -- do not respell. KEEP = [ 'Copyright (c) 1996-2000 Macromedia, Inc.', ] # The document is served as a raw file. Web servers have no mime mapping for # .md, so it goes out with no charset and browsers fall back to windows-1252, # which renders every UTF-8 em dash as mojibake. Keeping the output pure ASCII # makes it render identically no matter what charset the reader guesses. ASCII_FOLD = [ ('--', '--'), ('-', '-'), ('-', '-'), ('+', '+'), ('+', '+'), ('|', '|'), ('+', '+'), ('+', '+'), ('+', '+'), ('+', '+'), ('->', '->'), ('<-', '<-'), ('...', '...'), ('*', '*'), ('section ', 'section '), ('(c)', '(c)'), (''', "'"), (''', "'"), ('"', '"'), ('"', '"'), ('x', 'x'), (' ', ' '), ('e', 'e'), ('>=', '>='), ('<=', '<='), ('deg', 'deg'), ] def expand(text): def code(m): name = m.group(2).strip() body = open(os.path.join(HERE, name), encoding='utf-8').read().rstrip('\n') lang = 'python' if name.endswith('.py') else '' return '```%s\n%s\n```' % (lang, body) def include(m): name = m.group(2).strip() for cand in (os.path.join(BASE, 'extract', name), os.path.join(BASE, name)): if os.path.exists(cand): body = open(cand, encoding='utf-8').read().rstrip('\n') # Drop the included file's own H1 -- the host document frames it -- # then demote every remaining heading one level so the included # sections nest under the host section instead of competing with it. body = re.sub(r'\A#[^\n]*\n', '', body).lstrip('\n') body = re.sub(r'^(#{1,5}) ', r'#\1 ', body, flags=re.M) return body raise SystemExit('INCLUDE not found: %s' % name) # One pass over the source only. re.sub never rescans what it substituted, # which matters because this file's own docstring contains both markers and # gets injected verbatim by the CODE branch. def one(m): return (code if m.group(1) == 'CODE' else include)(m) return re.sub(r'', one, text) def americanize(text): holes = {} for i, k in enumerate(KEEP): token = '\x00KEEP%d\x00' % i if k in text: holes[token] = k text = text.replace(k, token) for a, b in US: text = text.replace(a, b) for token, k in holes.items(): text = text.replace(token, k) return text def to_ascii(text): for a, b in ASCII_FOLD: text = text.replace(a, b) bad = sorted({ch for ch in text if ord(ch) > 126}) if bad: print('WARNING: non-ASCII left in output: %r' % bad) return text def main(): args = [a for a in sys.argv[1:] if not a.startswith('--')] out = args[0] if args else DEFAULT_OUT text = to_ascii(americanize(expand(open(SRC, encoding='utf-8').read()))) targets = [out] # `--txt` also writes a byte-identical .txt twin. Web servers have no mime # mapping for .md, so it is served as application/octet-stream and browsers # download it instead of displaying it; .txt is text/plain everywhere. if '--txt' in sys.argv: targets.append(os.path.splitext(out)[0] + '.txt') for path in targets: open(path, 'w', encoding='utf-8').write(text) print('%s: %d lines, %.0f KB' % (path, text.count('\n') + 1, len(text.encode()) / 1024)) # Any marker still present came from an embedded script's own docstring -- # re.sub does not rescan its replacements, so those are inert by design. left = re.findall(r'', text) if left: print('markers inside embedded code (expected, not expanded): %s' % ', '.join(sorted(set(left)))) if __name__ == '__main__': main() ``` --- ## 13. About this teardown Produced by **FieRcE YeD**, 12 August 2026, for **[doors98.com](https://doors98.com)**. ### Why bother Square's Weakest Link is a 2001 Flash movie that circulated as a bare `.exe`. That was a reasonable way to ship a cartoon in 2001 and is close to the worst possible way to preserve one: it is an unsigned Windows binary, which is enough to make most people delete it on sight, and it carries a copy of a player Macromedia stopped shipping a very long time ago. Reading it instead of running it recovers the actual artifact -- a 1.1 MB SWF that any modern Flash emulator will play, the author's and composer's names, 41 pieces of sprite art, six sounds, the complete screenplay of a joke that depends on a character saying nothing at all, and one Kefka who has been walking in place just off the right-hand edge of the frame since 2001. ### Why a teardown and not a description The interesting content in a file like this is rarely the content it shows you. The movie is a nine-minute comic; the *file* also contains a pose palette the author dragged off-screen while working, five characters standing in the void after their elimination, and a fully animated movieclip that no human being has ever seen play. None of that is visible from watching it, and none of it is recoverable from a screen capture. That is why section 12 carries the actual code rather than a summary. A description of a format helps one person; a working decoder helps whoever finds the next file. The SWF specification is public and the format is genuinely simple -- the only reason these movies stay unexamined is that the dialogue is stored as glyph indices, so the obvious first move (run `strings` on it) returns nothing and people conclude there is nothing there. There is. The toolchain in section 12 works on any Flash-era projector, not just this one. ### doors98.com doors98.com is a Windows 98 desktop rebuilt in a browser -- a parody, an archive, and a place to put things that no longer have anywhere to live. It already hosts recreations and teardowns of several recovered applications from this era, including the POL Map Editor, Pokemon Online Advanced Beta 1.0, Epic's Memorial and POL Lounge V 1.0. Where things live: | | | |---|---| | The original executable | | | This document, raw | | | This document, as Markdown | | | This document, on the desktop | | | The download entry | | | Other recovered software of the era | | (The two document files are byte-identical. Web servers have no mime mapping for `.md`, so that copy is sent as `application/octet-stream` and browsers download it instead of showing it; the `.txt` is `text/plain` and renders inline, which is why the desktop viewer and the sitemap both point at it. The document is written in Markdown and kept pure ASCII so it reads correctly either way.) The findings, the format notes and the toolchain are the part worth keeping. ### Corrections Almost everything here was derived from one file. The byte-level findings are mechanical and reproducible by re-running section 12. The character identifications in section 6.1 are inference -- sprite art cross-checked against which bitmaps appear in each scene and who is speaking -- and the walk-cycle attributions in section 4.7 follow from placement positions rather than from anything labeled in the file. The exception is "Where it came from" in section 2, which is the only part sourced from outside the movie: the DHS distribution, the dhsrpg.net hosting and the DarthTOOPS identification all rest on archived web pages, and the last of those is a strong inference rather than a confirmed fact. If you were part of Dream Horizon Software, know Kristopher M. Toops or Shawn Overn, or have a copy of the original dhsrpg.net download page: corrections are genuinely welcome, and so are takedown requests from any rights holder named in section 10.