# POL Lounge V 1.0 -- complete teardown A full static analysis of **`POL Lounge V 1.0.exe`** (590,451 bytes, file date **4 May 2001**), a Multimedia Fusion 1.5 application by **Majin Vegeta**, from the Pokemon Online (POL) fan scene that orbited bulbagarden.com around 2000-2002. Everything below was recovered from that single file. No emulator, no debugger, and the program was never run. The container format, the artwork, the audio, the seven third-party DLLs embedded inside it, and the entire program logic were decoded from the bytes. **Section 10 explains how, and section 11 contains the complete toolchain**, about a thousand lines of Python, so you can do this to any MMF 1.5 application yourself. --- ## Contents 1. [What it is](#1-what-it-is) 2. [Provenance and identification](#2-provenance-and-identification) 3. [How it behaves](#3-how-it-behaves) 4. [Data inventory](#4-data-inventory) 5. [The program logic](#5-the-program-logic) 6. [Hidden and never-visible content](#6-hidden-and-never-visible-content) 7. [Bugs](#7-bugs) 8. [The servers and the dead links](#8-the-servers-and-the-dead-links) 9. [Credits and rights](#9-credits-and-rights) 10. [How to reproduce this](#10-how-to-reproduce-this) 11. [The toolchain](#11-the-toolchain) 12. [About this teardown](#12-about-this-teardown) --- ## 1. What it is Not a game. **POL Lounge is a multiplayer chat client with a walk-around lobby** -- a Pokemon-styled overworld where every connected user appears as a sprite moving in real time, plus a conventional text chat room you reach by walking to a particular spot. It ran against a public server on the Clickteam **MOO** network. That server has been gone for roughly two decades, which means the two screens that matter have not been seen by anyone in about that long. This teardown reconstructs them from the file. Three frames: | # | size | what it is | |---|---|---| | 0 | 500x400 | title -- blue gradient, tiled trainer wallpaper, and a modal box asking **"Choose a User Name"** | | 1 | **800x600** | **the Lounge** -- a scrolling overworld with buildings, trees and other users | | 2 | 500x400 | the text chat room | Flow, as encoded in the event tables: ``` frame 0 --[name entered]--> frame 1 (Lounge) frame 1 --[walk into an invisible trigger]--> frame 2 (chat room) frame 2 --[click the sprite beside the hint text]--> frame 1 (Lounge) ``` --- ## 2. Provenance and identification | | | |---|---| | Application name | `POL Lounge V 1.0` | | Author | `Majin Vegeta` | | Editor source file | `POL Lounge V 1.0.gam` | | Build target | `POL Lounge V 1.0.exe` | | Authoring tool | Multimedia Fusion 1.5 (`PAME` container, runtime version 0x0300) | | Runtime stub | `CNCRT32.EXE` -- *"Multimedia Fusion Stand Alone Application"* v2.2.98, (c) 1998 IMSI & Europress Software | | Runtime PE timestamp | **2000-05-03 11:20:38 UTC** | | Linker | 4.20, GUI subsystem, image base 0x400000, 7 sections | | File date | 4 May 2001 | | Preserved at | [archive.org/details/doors98-pol-lounge-1.0](https://archive.org/details/doors98-pol-lounge-1.0) | The executable this describes is mirrored at ****, so every offset, size and checksum quoted below can be checked against the same bytes. Both paths recorded in the file point at the Windows Desktop of a Windows 9x machine -- he built it straight off the desktop, and the application is exactly one year and one day younger than the runtime stub it was built on. The author's handle is a Dragon Ball Z reference, and so is his avatar: the player sprite, named `POL MAN` in the file, is a **Vegeta** sprite -- olive spiked hair, Saiyan armor with a red chest plate. The wallpaper tile that covers the title screen and the chat room is a different character entirely, a chibi trainer in a backwards cap. --- ## 3. How it behaves ### Frame 0 -- title Two event groups, and that is the whole screen: 1. On start of frame, open a modal input dialog titled **"POL Lounge V 1.0"** with the prompt **"Choose a User Name"**. 2. When OK is clicked, store the text in **global string 0** and jump to the Lounge. The background is a vertical gradient from RGB (0,147,231) to (0,55,211), with the 57x54 trainer tile laid over it on a 64-pixel grid, 8 columns by 6 rows. ### Frame 1 -- the Lounge (800x600) Forty-one event groups, organized into named groups the author left in the file: **Startup**, **New/Old Guy**, **Tell Position**, **Get Position**, **Scrolling**, **Boundaries**. - **Startup** connects to the server, destroys the remote-player prototype sprite, and starts the music looping. Once connected it signs on with your name (global string 0) on channel `pollounge`. - **You** are `POL MAN`: Eight Directions movement, player 1, speed 23, acceleration 100, deceleration 100, all 32 directions enabled, starting at (300,300). - **Other users** are copies of `POL MAN 2`, created at (300,300) as people join, each tagged with the sender's MOO user id in an alterable value so incoming messages route to the right sprite. - **Tell Position** broadcasts your state every **250 ms**: X on subchannel 1, Y on subchannel 2, direction on subchannel 3, plus `"walk"` on subchannel 10 and `"stop"` on subchannel 11 when your animation state changes. - **Get Position** does the reverse for everyone else, guarded by a `UserId() != GetId()` check so your own broadcast never moves you. - **Scrolling** centers the display on you every loop, and writes the ping into a counter every 2 seconds. - **Boundaries** stops you on background collision and at each of the four playfield edges. - Hovering the mouse over another user shows their id in a text object at the top left labeled **Ping ID**. - `ding` plays when somebody joins; `chimes` when somebody leaves. - Walking into an invisible trigger at (368,144) jumps to the chat room. ### Frame 2 -- the chat room (500x400) Green gradient wallpaper with the same trainer tile. Live controls, with exact geometry read from the extension data blobs: | control | kind | position | size | notes | |---|---|---|---|---| | chat log | list box | (14,13) | 495x128 | receives every message and join/leave line | | Prompt | edit box | (73,169) | 337x18 | font "System" | | **Send** | button | (434,168) | 72x20 | **wired to nothing -- see section 7** | | Sign On | button | (18,197) | 74x24 | | | Alias | edit box | (198,200) | 119x18 | | | Connect | button | (-226,150) | 74x24 | parked off screen, then hidden | | IP | edit box | (-222,313) | 153x18 | parked off screen, then hidden | | Port | edit box | (455,218) | 50x18 | hidden at start of frame | | Channel | edit box | (396,244) | 119x18 | hidden at start of frame | Static text objects, verbatim, all in the application's default font: | text | position | color | |---|---|---| | `Prompt:` | (10,171) | cyan (0,255,255) | | `Name/Alias:` | (109,201) | cyan | | `Server Port:` | (375,217) | cyan -- erased at start of frame | | `Channel:` | (330,243) | cyan -- erased at start of frame | | `Server IP/Host:` | (-178,244) | cyan -- off screen and erased | | `Message of the Day (MOTD)` | (161,309) | yellow -- erased, then refreshed from the server every 1000 ms | | `Status: Not Connected` | (-109,252) | navy -- **off screen**, but updated constantly | | `alias` | (24,-60) | navy -- off-screen scratch buffer | | `Message` | (21,-38) | navy -- off-screen scratch buffer | | `Ping ID` | frame 1, (16,16) | navy | Behavior: pressing **Enter** with a non-empty prompt sends the text on subchannel 1 and clears the box. Incoming subchannel-1 messages are assembled as `alias: message` and appended to the list. Joins and leaves append `X is in the chat room`, `X has entered`, `X has left`. --- ## 4. Data inventory ### 4.1 Container `PAME` block appended to the PE at file offset **0x86291**, runtime version 0x0300. Twenty chunks, ending exactly at end of file -- there is no slack space and no deleted data anywhere in the overlay. | offset | id | chunk | stored | inflated | |---|---|---|---|---| | 0x0862a1 | 0x2223 | AppHeader | 40 (deflated) | 84 | | 0x0862d1 | 0x2224 | AppName | 24 (deflated) | 17 | | 0x0862f1 | 0x2225 | AppAuthor | 20 (deflated) | 13 | | 0x08630d | 0x222e | EditorFilename | 47 (deflated) | 40 | | 0x086344 | 0x222f | TargetFilename | 47 (deflated) | 40 | | 0x08637b | 0x2233 | AppIcon | 99 (deflated) | 640 | | 0x0863e6 | 0x2226 | AppMenu | 87 (deflated) | 120 | | 0x086445 | 0x2228 | Extensions | 91 (deflated) | 148 | | 0x0864a8 | 0x2229 | FrameItems (53 objects) | 5300 | 5300 | | 0x087964 | 0x222b | FrameHandles `[1,0,0,2]` | 11 (deflated) | 8 | | 0x087977 | 0x3333 | Frame 0 | 1256 | 1256 | | 0x087e67 | 0x3333 | Frame 1 | 2387 | 2387 | | 0x0887c2 | 0x3333 | Frame 2 | 1983 | 1983 | | 0x088f89 | 0x6666 | ImageBank (53) | 14059 | 14059 | | 0x08c67c | 0x5555 | ImageOffsets | 175 (deflated) | 232 | | 0x08c733 | 0x6668 | SoundBank (2) | 13301 | 13301 | | 0x08fb30 | 0x5557 | SoundOffsets | 15 (deflated) | 8 | | 0x08fb47 | 0x6669 | MusicBank (1) | 1808 | 1808 | | 0x09025f | 0x5558 | MusicOffsets | 4 | 4 | | 0x09026b | 0x7f7f | Last | 0 | 0 | `Jump to frame` does **not** carry a frame index. It carries an index into the `FrameHandles` table `[1, 0, 0, 2]`, which is why the title screen's "jump to handle 0" reaches frame 1 and the Lounge's "jump to handle 3" reaches frame 2. Read literally, the navigation looks circular and broken. ### 4.2 Application header Window **500x400**, graphic mode 4 (24-bit), 3 frames, border color black, initial score and lives 0. Four player control slots, all keyboard-mapped. ### 4.3 Menu The application replaces the runtime's stock menu with two items: ``` &Pol Lounge &New id 1010 ----- &Quit id 1009 About &Author id 1024 ``` The accelerator block in the application's own menu chunk is **empty**, so `&New` shows no F2. But 1010, 1009 and 1024 are the *runtime's* own ids -- 1024 is the runtime's `&About...`, simply relabeled. Which means the About box you get is Clickteam's, not the author's (section 4.10). ### 4.4 Icon A **Poke Ball**, stored as a raw 32x32 4-bit icon (512 bytes of pixels bottom-up, then a 128-byte AND mask) in its own chunk, byte-identical to the icon in the PE's resources. The **16x16 icon is garbage** -- a gray checkerboard -- and that is what Windows shows in the title bar. Original defect, not a decoding artifact. ### 4.5 Objects -- 53 total | type | count | which | |---|---|---| | Backdrop | 15 | scenery, some marked as obstacles | | Text | 10 | the labels and scratch buffers listed in section 3 | | QuickBackdrop | 9 | gradients, panels, motif-filled walls | | Active | 5 | POL MAN, POL MAN 2, Player 1, and two `Active object 1` | | `kcedit.cox` | 5 | Prompt / IP / Port / Alias / Channel | | `kcbutton.cox` | 3 | Send / Connect / Sign On | | `kclist.cox` | 1 | the chat log | | `kcini.cox` | 1 | `Save.INI` -- never used | | `kcinput.cox` | 1 | the username dialog | | `Gstrings.cox` | 1 | global strings | | `Online.cox` | 1 | the MOO network object | | Counter | 1 | the ping counter | Frame 0 uses 4 of them, frame 1 uses 23, frame 2 uses 29. ### 4.6 Animation tables MMF stores one frame list per animation per direction, on a 32-direction compass where 0 = east, 8 = north, 16 = west, 24 = south. `POL MAN` and `POL MAN 2` share one table: | animation | dir 0 (E) | dir 8 (N) | dir 16 (W) | dir 24 (S) | |---|---|---|---|---| | Stopped | 23 | 24 | 25 | 26 | | Walking | 27, 23 | 28, 24, 29, 24 | 30, 25 | 31, 26, 32, 26 | Note the asymmetry: the side views get a two-frame cycle, the front and back views get a four-frame cycle alternating left and right steps. That is the standard Pokemon-overworld walk pattern. `Player 1` (chat room only, never animated) uses the same shape with images 35-38 standing and 39-44 walking. ### 4.7 Images -- all 53 Handles are sparse -- there is no 3, 8, 9 or 56 -- and the bank is stored unsorted. "RLE" marks images whose color plane is run-length encoded. "On screen" is whether any instance of any object using that image is ever visible; see section 6 for why 20 of them are not. | handle | size | hotspot | RLE | on screen | what it is | |---|---|---|---|---|---| | 1 | 34x32 | 17,16 | - | **no** | grass tile, unused variant | | 2 | 24x54 | 12,27 | - | yes | vending machine | | 4 | 128x64 | 64,32 | - | yes | small building | | 5 | 32x27 | 16,13 | - | yes | sign board | | 6 | 224x129 | 112,64 | - | yes | large building | | 7 | 32x32 | 16,16 | - | yes | planter / barrel | | 10 | 8x15 | 0,0 | - | **no** | counter digit 0 | | 11 | 8x15 | 0,0 | yes | **no** | counter digit 1 | | 12 | 8x15 | 0,0 | yes | **no** | counter digit 2 | | 13 | 8x15 | 0,0 | yes | **no** | counter digit 3 | | 14 | 8x15 | 0,0 | yes | **no** | counter digit 4 | | 15 | 8x15 | 0,0 | yes | **no** | counter digit 5 | | 16 | 8x15 | 0,0 | yes | **no** | counter digit 6 | | 17 | 8x15 | 0,0 | yes | **no** | counter digit 7 | | 18 | 8x15 | 0,0 | yes | **no** | counter digit 8 | | 19 | 8x15 | 0,0 | yes | **no** | counter digit 9 | | 20 | 4x15 | 0,0 | yes | **no** | counter minus sign | | 21 | 32x64 | 16,32 | - | yes | pine tree | | 22 | 32x32 | 20,20 | yes | **no** | boulder tile -- motif of the unused walls | | 23 | 23x32 | 11,16 | - | yes | POL MAN standing, east | | 24 | 28x32 | 14,16 | yes | yes | POL MAN standing, north | | 25 | 23x32 | 11,16 | yes | yes | POL MAN standing, west | | 26 | 28x32 | 14,16 | yes | yes | POL MAN standing, south | | 27 | 24x30 | 12,15 | yes | yes | POL MAN walking | | 28 | 28x30 | 14,15 | yes | yes | POL MAN walking | | 29 | 28x30 | 14,15 | yes | yes | POL MAN walking | | 30 | 24x30 | 12,15 | yes | yes | POL MAN walking | | 31 | 28x30 | 14,15 | yes | yes | POL MAN walking | | 32 | 28x30 | 14,15 | yes | yes | POL MAN walking | | 33 | 288x95 | 144,47 | - | yes | pale UI panel | | 34 | 66x10 | 0,0 | - | **no** | the hidden warp trigger bar | | 35 | 32x32 | 0,0 | - | yes | Player 1 standing, east | | 36 | 32x32 | 0,0 | yes | yes | Player 1 standing, north | | 37 | 32x32 | 0,0 | yes | yes | Player 1 standing, west | | 38 | 32x32 | 0,0 | yes | yes | Player 1 standing, south | | 39-44 | 32x32 | 0,0 | yes | yes | Player 1 walking | | 45 | 152x29 | 0,0 | - | yes | *"Click the POL man to go back to the Lounge"* | | 46 | 12x18 | 0,0 | yes | **no** | counter plus | | 47 | 12x18 | 0,0 | yes | **no** | counter point | | 48 | 12x18 | 0,0 | yes | **no** | counter exponent "e" | | 49 | 57x54 | 28,27 | - | yes | wallpaper tile -- chibi trainer, backwards cap | | 50 | 32x32 | 16,15 | - | yes | small bush | | 51 | 32x32 | 16,16 | - | **no** | white keypad machine -- cut | | 52 | 32x64 | 16,32 | - | **no** | red and white post sign -- cut | | 53 | 32x32 | 16,16 | - | **no** | pale speckled tile -- cut | | 54 | 32x32 | 16,15 | - | yes | planter, the Lounge cluster | | 55 | 64x64 | 32,32 | - | yes | large tree | | 57 | 16x16 | 0,0 | - | yes | grass tile -- the one actually used | All 53 decode losslessly. There is no palette: mode 4 stores 24-bit BGR triples with a two-byte row pad, and transparency comes from color-keying (0,0,0) rather than from an alpha plane. ### 4.8 Audio **Two samples**, both 22,050 Hz, 8-bit, mono -- precisely the Windows 95/98 system-sound format, and named after `DING.WAV` and `CHIMES.WAV`. They do not match the files Windows ships under those names today (Microsoft replaced them after XP), so these are very likely the 9x-era originals lifted from the system Media folder. | name | frames | duration | used for | |---|---|---|---| | `ding` | 11,554 | 0.524 s | a user joins | | `chimes` | 15,876 | 0.720 s | a user leaves | **One music track**, named `village` -- an embedded **MIDI**, format 1, 11 tracks, division 384, 120 BPM, **63.0 s**, 4,749 bytes. It loops in the Lounge only. Its own text meta-events, one per track, read: > `"Serge's Village Theme"` * `from Chrono Cross` * `Original by` * > `Yasunori Mitsuda` * `Sequenced by` * `Wilbert Roget, II` * > `liu_kang [at] geocities.com` * `All Rights Reserved` * > `Send me lots of comments/questions/requests!` * > `(just mute chan 6 if the birds get annoying ;)` A 1990s fan MIDI transcription of a Square soundtrack, credits and GeoCities address intact, embedded in a Pokemon fan chat client. See section 9. ### 4.9 Seven complete DLLs are embedded in the file The 266 KB between the end of the PE sections and the game data is not padding. It is seven whole extension DLLs, each stored as: ``` u32 0x0000222C record marker u32 size char name[] NUL-terminated, e.g. "Online.cox" byte dll[] the entire PE, MZ header onward ``` The list is terminated by a `0x7f7f` marker. All seven carve out as complete, loadable binaries. | file | what it is | author | PE timestamp | |---|---|---|---| | `Online.cox` | **MOO (Multiplayer Online Object) v1.20** | Sean Poling, Third Eye Entertainment, (c) 1998 | 1999-01-18 | | `kcinput.cox` | Input dialog object | Marcello Bastea-Forte, Cellosoft, (c) 1999-2000 | 2000-08-21 | | `Gstrings.cox` | SJi Global Strings Object | Jamie Beatson, MMF conversion by MI Studios, (c) 2000 SJi | 2000-10-28 | | `kcedit.cox` | *"Click & Create Edit Object"* | Clickteam / IMSI & Europress, (c) 1996-1998 | 2000-05-03 | | `kcbutton.cox` | *"Click & Create Button object"* | same | 2000-05-03 | | `kclist.cox` | *"Click & Create List Object"* | same | 2000-05-03 | | `kcini.cox` | *"Click & Create Ini Object"* | same | 2000-05-03 | Two observations. The four Clickteam objects still identify themselves internally as **Click & Create** (1996) even though their version resources say "Multimedia Fusion X Object" -- MMF 1.5 shipped 1996-era binaries with newer labels, and three of the four were rebuilt within four minutes of each other on the same morning as the runtime stub. And the MOO object carries a sales pitch that outlived its product: > *"If you enjoy MOO, you may want to check out MOO2, ... MOO can be > distributed freely."* **This is the key that unlocks the program logic.** Each extension keeps its condition, action and expression menu strings in its own PE string table at ids **28000+N**, **28200+N** and **28400+N**. Pull those out and every opcode in the application's event tables gets a name. `Online.cox`, in order: | # | conditions (28000+) | actions (28200+) | expressions (28400+) | |---|---|---|---| | 0 | Is connected | Connect to %s through port %s | `GetAlias$(` | | 1 | Has line to load from subchannel %s | Disconnect | `GetId(` | | 2 | Has errors | Sign on as %s on channel %s | `GetChannel$(` | | 3 | Is signed on | Sign off | `RecText$(` | | 4 | New client was signed on | Set alias to %s | `UserId(` | | 5 | New client just signed on | Set channel to %s | `UserAlias$(` | | 6 | Client signed off | Send %s through subchannel %s | `ErrorMsg$(` | | 7 | | Display error | `ErrorNum(` | | 8 | | | `ServerMsg$(` | | 9 | | | `ServerVer(` | | 10 | | | `MsgId(` | | 11 | | | `MsgAlias$(` | `kcinput.cox` supplies `Open Input Dialog (String, Title=%s, Description=%s)` as action 0 and `OK Button Clicked` as condition 0; `Gstrings.cox` supplies `Set String %s to %s` and `gstring$(`. The four Clickteam objects predate the convention and carry no ACE strings at all, so their opcodes stay numeric. ### 4.10 The runtime's own resources Because the host is a stock Clickteam runtime, its resources are all still present, and three of them are directly visible in POL Lounge. **The About box is the runtime's.** Dialog resource 705, 226x61 dialog units, 8 pt Helv: an OK button at (170,8), a 32x32 icon at (8,7), the application name at (45,8), and `"By " + author` at (45,34). There is a fourth control -- a `Copyright ...` line -- placed at **y = 149 inside a 61-unit-tall dialog**, so it is clipped off the bottom and can never be seen by anyone. That is a defect in Clickteam's runtime, present in every MMF 1.5 application ever built. **The full stock menu is still in the binary** -- `Application / Options / Help` with Password, Pause, Players, Play samples, Play musics, Hide the menu, Full Screen, How to use..., About... -- and so is its **accelerator table**: F1, F2, F8, Ctrl+P, Ctrl+Y, Ctrl+S, Ctrl+M, Alt+Enter. POL Lounge hides nearly all of those menu items, but accelerators are a separate resource, so the hotkeys very likely still fire. Also present and never reached here: *"Application paused"*, *"Keyboard setup"* (`Press a key... / or ESC to cancel.`), *"Player controls"*, *"Enter password"*, and two color-depth warnings, one of which reads *"This application was created in a True / High color mode, running it in 256 color mode may result in slower loading times."* --- ## 5. The program logic Fifty-nine event groups across three frames -- 2, 41 and 16. A few worth quoting: ``` [1.1] IF Start of frame DO Connect to "moo.iclickzone.com" through port 1200 DO Destroy POL MAN 2 DO Play looping music "village" [1.3] IF Online Object Has errors DO Display error DO End the application [1.4] IF Is connected AND NOT Is signed on DO Sign on as gstring$(0) on channel "pollounge" [1.14] IF Every 250 ms DO Send Str$(X("POL MAN")) through subchannel 1 DO Send Str$(Y("POL MAN")) through subchannel 2 DO Send Str$(Dir("POL MAN")) through subchannel 3 [1.19] IF Has line to load from subchannel 1 AND pick POL MAN 2 whose value 0 = MsgId() AND UserId() <> GetId() DO Set X position of POL MAN 2 to Val(RecText$(1)) [1.36] IF POL MAN collides with Active object 1 DO Jump to frame handle 3 -> frame 2 [2.15] IF Player 1 is clicked DO Jump to frame handle 0 -> frame 1 ``` Group `[1.3]` is the one that matters today: **any connection error makes the application display the MOO error dialog and then close itself.** With the server gone, that is almost certainly what happens the moment you enter a name. --- ## 6. Hidden and never-visible content Every image in the file is referenced by some object, so there is no orphan artwork. But **20 of the 53 are attached to objects that are hidden or parked outside their frame**, and a good deal of the chat room is buried under its own wallpaper. ### Cut scenery, dragged off the map and forgotten | image | size | left at | what it is | |---|---|---|---| | `51` | 32x32 | (848,512) -- past the right edge of an 800-wide frame | a white machine with a keypad and gold trim | | `52` | 32x64 | (-31,400) -- past the left edge | a red and white sign or mailbox on a gray post | | `53` | 32x32 | (-111,320) -- past the left edge | a pale speckled tile, snow or blank floor | | `1` | 34x32 | (-63,240) -- past the left edge | a **lusher grass tile** than the one actually used; the Lounge's ground is the plain 16x16 tile `57` instead | ### Two enormous unused walls `QuickBackdrop` objects **1600x32 at (0,1568)** and **32x3488 at (1568,0)**, both motif-filled with the boulder tile `22`. Both sit thousands of pixels outside an 800x600 frame. They look like playfield boundaries abandoned in favor of the four `Leaving playfield -> Stop` events. ### The warp is invisible `Active object 1` at **(368,144)** carries image `34`, a 66x10 dark red bar, and the frame's start-of-frame event runs **Hide** on it. On the rendered map there is a conspicuous gap in the 4x2 block of planters spanning (336..464, 112..176) -- **that gap is the door to the chat room**, and nothing marks it. Hidden objects still collide in MMF, so it works; you simply have to already know it is there. ### The ping counter is off screen `Counter 1`, 20x128, sits at **(72,-48)** -- above the top edge of the Lounge. An event faithfully writes the ping into it every two seconds. Its entire red digit font is therefore never drawn: images 10-19 (the digits), 20 (minus), 46 (+), 47 (.), 48 (e). Thirteen images. ### A whole chat UI is buried under the wallpaper Frame 2 lays down four `QuickBackdrop` panels first -- - 520x17 at (0,0), gradient red (255,0,16) -> (40,0,0) - 520x64 at (0,17), black -> gray - 520x87 at (0,81), gray (96,96,96) -> black - 520x172 at (0,168), dark red -- and then places a 523x404 green gradient **over all four of them**. They are the kcOnline demo's original chat layout. The author covered it rather than deleting it, and it has been sitting under the green wallpaper ever since. ### Objects parked off frame Eight in total, including the `Status:` text at x = -109 that events update to "Connected" / "Signed On" / "Not Connected" every single loop, where nobody can read any of it. --- ## 7. Bugs All verified from the decoded event tables, not from guesswork. **The "Send" button does nothing.** It is visible in the chat room at (434,168), 72x20, labeled `Send` -- and **no event anywhere in the file references it**. The only way to send a message is to press Enter. **The chat room signs you in as the wrong person, on the wrong channel.** The Lounge does it correctly: alias = the name you typed, channel = `pollounge`. But the chat room's start-of-frame still runs the demo's setup, writing `"3ee.com"`, `"1200"`, `"user"` and `"demochat"` into the four edit boxes before hiding three of them. The Sign On button reads those boxes. So signing on from the chat room makes you **`user`** in **`demochat`**. The underlying cause is placement: the Global Strings object is present in frames 0 and 1 but **not** in frame 2, so that frame has no way to read the name you typed. **Any connection error ends the application** -- see `[1.3]` above. **Pressing Enter while not connected restarts the whole application.** **`Save.INI` is dead.** The `kcini` object is placed in the chat room and names a settings file. No event ever reads or writes it. **999 of 1000 global strings are unused**, and the Global Strings object's only condition, "Compare two Values", is never used either. **Probable duplicate avatar.** The Lounge already contains a `POL MAN` instance at (300,300), and the "signed on" event *creates another* `POL MAN` at (300,300). In MMF every instance of an object with a player-controlled movement responds to the same keys, so a successful sign-on would plausibly leave you driving two overlapping sprites. This one is unverified -- it needs a working server to observe. --- ## 8. The servers and the dead links | host | port | where it appears | |---|---|---| | **`moo.iclickzone.com`** | **1200** | hardcoded; dialed at the start of both the Lounge and the chat room | | `3ee.com` | 1200 | written into the hidden IP box at startup and never actually used | Channels: **`pollounge`** (the Lounge) and **`demochat`** (the chat room's leftover). **`iclickzone.com` was "iCLICKZONE.COM :: Global MOO Network"**, a public hub for Clickteam's MOO multiplayer object. The site was a frameset pointing at `i-corenet.com/iclick` in 2001 and `networktronics.com/iclickzone` in 2002. The Wayback Machine holds the frameset from July 2001 to November 2002; the inner pages were never captured, so the title is essentially all that survives of it. **`3ee.com` is Third Eye Entertainment**, the company of Sean Poling, who wrote the MOO object. Its own About box says *"Check moo.3ee.com for more information."* So the `3ee.com` in the IP box was never Majin Vegeta's choice -- it is the demo default that shipped with the object, left untouched. Other dead links recovered from the embedded extensions: - `http://www.3ee.com/`, `moo.3ee.com` -- Third Eye Entertainment - `http://www.cellosoft.com/` -- Cellosoft - `http://www.mistudios.net/` -- MI Studios - `www.sjinteractive.co.uk` -- SJ Interactive - `http://www.clickteam.com` - `suprelfman [at] aol.com`, `jamie [at] saintees.com`, `liu_kang [at] geocities.com` *(Email addresses are lightly obfuscated in this document only. They appear unaltered in the binaries, which are preserved as found. All three domains are long dead.)* --- ## 9. Credits and rights This is a fan application assembled from other people's work, and the parts have different origins and different terms. Recorded here so nobody has to guess. | part | origin | stated terms | |---|---|---| | The application | Majin Vegeta, 2001 | none stated | | `Online.cox` (MOO v1.20) | Sean Poling / Third Eye Entertainment, (c) 1998 | *"MOO can be distributed freely."* | | `kcinput.cox` | Marcello Bastea-Forte / Cellosoft, (c) 1999-2000 | *"freeware under terms of licence. You cannot use it for commercial use."* | | `Gstrings.cox` | Jamie Beatson / SJ Interactive; MMF conversion by MI Studios, (c) 2000 SJi | none stated | | `kcedit`, `kcbutton`, `kclist`, `kcini` | Clickteam / IMSI & Europress Software, (c) 1996-1998 | commercial components of Click & Create / MMF 1.5 | | `CNCRT32.EXE` runtime | Clickteam / IMSI & Europress, (c) 1998 | commercial | | `village` MIDI | composition by **Yasunori Mitsuda** (Chrono Cross, Square, 1999); sequence by **Wilbert Roget, II** | *"All Rights Reserved"* | | `ding`, `chimes` | almost certainly Microsoft, Windows 95/98 system sounds | -- | | Sprites and tiles | derived from Pokemon-era artwork (Nintendo / Game Freak / The Pokemon Company); the player avatar is a Dragon Ball Z Vegeta sprite | -- | Practical notes for anyone redistributing any of this: - The four Clickteam objects are the only components here that are pieces of a *commercial* toolchain. Inside a compiled application they are ordinary redistribution; extracted as standalone loadable DLLs they are something else, and Clickteam still exists. - The MIDI carries two stacked claims -- Square's composition and the sequencer's own reservation of rights. Preserving the `.mid` as an artifact of the file is one thing; publishing a rendered recording of it is a different act. - No user data of any kind is present. No member lists, no chat logs, no IP addresses, no credentials. The chat was server-side and ephemeral, and the one settings file the application names is never written. The only personal data in the whole file is the three author email addresses above, all of which shipped publicly in the extensions and on the period websites. --- ## 10. How to reproduce this Everything here was derived from the file itself, but two pieces took a while to find, so here they are outright. ### 10.1 The container ``` "PAME" u16 runtimeVersion u16 runtimeSubversion u32 productVersion u32 productBuild chunk* ( u16 id, u16 flags, u32 size, u8 data[size] ) until id == 0x7f7f ``` Locate the `PAME` magic **nearest the end of the file whose following chunk id is `0x2223`** -- the runtime also contains the literal string `PAME` in its code section, which is a decoy. `flags == 0` means the payload is raw. `flags == 1` means it is `u32 decompressedSize` followed by a compressed stream. ### 10.2 The compression, which is the whole trick **The compressed chunks are not zlib, and they are not RFC 1951 deflate.** Every off-the-shelf inflate rejects them. Clickteam shipped a modified copy of Joergen Ibsen's *tinflate*, and it differs in four places: | | RFC 1951 | Clickteam | |---|---|---| | block type | 2 bits, read **after** BFINAL | **3 bits, read before BFINAL** | | block type values | 0 stored / 1 fixed / 2 dynamic | **7 stored / 5 fixed / 6 dynamic** | | stored block | `LEN` then `~LEN` | **`LEN` only, no check word** | | code-length order | `16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15` | **`18,17,16,0,1,2,3,...,15`** | Everything else -- the fixed trees, the length and distance base tables, the symbol decoder -- is stock tinf. The tell that confirms you have it right: a chunk whose payload begins `0F xx xx` is a *stored* block of length `xx xx`, because `0x0F` read least-significant-bit first is `btype = 7, bfinal = 1`. The application name chunk is exactly this, and decodes to a readable string immediately. ### 10.3 The embedded extension DLLs Between the end of the PE sections and the `PAME` magic, scan for records: ``` u32 0x0000222C u32 size char name[] NUL-terminated, ends ".cox" byte dll[] starts "MZ" ``` Carve each DLL from its `MZ` to the start of the next record -- the declared size runs a few bytes long, so trust the boundaries instead. Then read each DLL's PE **string table**: ids `28000+N` are its conditions, `28200+N` its actions, `28400+N` its expressions, each prefixed with a two-digit code. Without this step the event tables are unreadable numbers; with it they are English. ### 10.4 The rest of the format The frame, object, instance and event layouts are documented in Mathias Kaerlev's **Anaconda / mmfparser** (`mmfparser/data/chunkloaders/onepointfive/all.py`), which is the only complete public description of the MMF 1.5 structures. `mmfparser/data/tinflate.c` in the same project is the source of truth for section 10.2, and `imagebank.pyx` covers the image bank -- RLE and raw planes, 8/15/16/24-bit modes, row padding, alpha planes. Three traps worth stating explicitly: - **`Jump to frame` indexes the `FrameHandles` chunk (`0x222b`), not the frame list.** Read it literally and the navigation looks circular and broken. - **`QuickBackdrop` width and height are 16-bit in MMF 1.5**, not 32-bit as in MMF 2. If you get 26,214,900 for a width, that is why. mmfparser does not cover this: its MMF 1.5 path drops into an interactive editor rather than parsing the structure. - **Condition and action opcode numbering is not symmetric.** For an extension object the action index is `num - 80` and the condition index is `-num - 81`; for the built-in object types the number indexes the shared "common object" table directly, negative for conditions and positive for actions. ### 10.5 A prompt for doing this yourself Hand an agent the file and something like this: > I have a Windows executable built with Clickteam Multimedia Fusion 1.5. > I want a complete static teardown: every image, sound, and music track > decoded to modern formats, every object and its placement, and the whole > event/logic table rendered in readable English. Do not run the program. > > Work in this order, verifying each step before moving on: > > 1. **Container.** Find the `PAME` magic nearest the end of the file whose > next `u16` is `0x2223` (the runtime has a decoy copy of the string). > Header is `"PAME", u16 runtimeVer, u16 runtimeSubVer, u32 productVer, > u32 productBuild`, then chunks of > `u16 id, u16 flags, u32 size, byte data[size]` until id `0x7f7f`. > Check the chain lands exactly on end of file. > > 2. **Decompression.** `flags == 1` payloads are `u32 decompressedSize` plus > a *Clickteam-variant deflate* -- NOT zlib, NOT RFC1951. Block type is > 3 bits read BEFORE the final-block bit, with values 7 = stored, > 5 = fixed Huffman, 6 = dynamic Huffman; stored blocks carry `u16 LEN` > with no complement word; and the code-length-code order is > `18,17,16,0,1,2,...,15`. Everything else is standard deflate. Port > `tinflate.c` from github.com/Matt-Esch/anaconda > (`mmfparser/data/tinflate.c`). Sanity check: the app-name chunk should > inflate to a readable string, and every chunk's inflated length must > equal its declared `decompressedSize`. > > 3. **Extensions.** Between the last PE section and the `PAME` magic, carve > records of `u32 0x222C, u32 size, name\0, `. Then read > each DLL's PE string table: **28000+N = conditions, 28200+N = actions, > 28400+N = expressions**. You need these to name the opcodes later. > > 4. **Banks and structures.** Use > `mmfparser/data/chunkloaders/onepointfive/all.py` and `imagebank.pyx` > from the same repository as the byte-layout reference for the image bank, > sound bank, music bank, frames, objects, instances and events. Note that > `QuickBackdrop` width/height are **16-bit** in 1.5, and that the > `Jump to frame` action parameter indexes the `FrameHandles` chunk > (`0x222b`), not the frame list. > > 5. **Events.** Decode every event group, condition, action and parameter, > and print them with the names from step 3. Resolve object references to > object names so each line reads like the original editor. Remember that > extension actions index at `num - 80` and extension conditions at > `-num - 81`. > > 6. **Verification.** Composite each frame from its instance list and compare > against any screenshot you have -- a correct decode should be > pixel-identical on a static screen. Then produce a per-image visibility > report: images attached to hidden or off-frame objects are usually the > interesting part. > > Report anything the program never shows: hidden objects, objects parked > outside the frame, unused images, and controls no event references. --- ## 11. The toolchain What follows is the complete set of scripts used to produce everything above. They need only Python 3 and Pillow, and they are written to be read as much as run -- the byte layouts are documented in the docstrings. Dependency order is the order they appear in. `pame_extract` is the spine; everything else imports it. ``` tinf.py the Clickteam deflate variant (no dependencies) pame_extract.py container, banks, frames, objects (tinf) objects_parse.py per-object properties (pame_extract) pe_resources.py PE resource walker (no dependencies) extensions.py carve the .cox DLLs, read their ACE menus (pame_extract, pe_resources) events_parse.py the event tables (pame_extract, objects_parse) render_frames.py composite frames, find unused images (pame_extract, objects_parse) dump.py write every asset to disk (pame_extract) midi_info.py MIDI headers and text credits (no dependencies) ``` A typical run: ``` python3 dump.py # images, sounds, music, raw chunks, JSON metadata python3 extensions.py # the 7 DLLs + their ACE tables + the icon python3 objects_parse.py # every object's decoded properties python3 events_parse.py # EVENTS.txt -- the whole program in English python3 render_frames.py # frame composites + the visibility report python3 midi_info.py sounds/village.mid ``` ### 11.1 `tinf.py` -- the decompressor The one piece with no public description anywhere. Without this every compressed chunk is opaque, which is why MMF 1.5 files have stayed largely unexamined. It is a direct port of Clickteam's modified tinflate; the four divergences from RFC 1951 are documented at the top and summarized in section 10.2. `uncompress()` returns the data *and* the number of bytes consumed, which matters because the image, sound and music banks pack one compressed stream per record with no length prefix -- you have to know where each one ended to find the next. ```python """ tinf.py -- Python port of the Clickteam-variant "tiny inflate" used by The Games Factory / Multimedia Fusion 1.x to compress `PAME` chunks. This is DEFLATE *almost*, but Clickteam's build of Joergen Ibsen's tinflate differs from RFC1951 in four places, which is why `zlib.decompressobj(-15)` can never read these chunks: 1. The block type is **3 bits** and is read **before** the BFINAL bit (RFC1951: 1 bit BFINAL then 2 bits BTYPE). Values are 7 = stored, 5 = fixed huffman, 6 = dynamic huffman. 2. A stored block carries only a **u16 LEN** -- there is no ~LEN check word. 3. The code-length-code ordering (`clcidx`) is `18,17,16,0,1,2,...,15` instead of RFC1951's `16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15`. 4. Bit 0 of the first byte is therefore the LSB of BTYPE, so a chunk whose payload begins `0F xx xx` is simply a stored block of length `xx xx`. Everything else -- the fixed trees, the length/distance base tables, the symbol decoder -- is stock tinf. Source of truth: `mmfparser/data/tinflate.c` from Mathias Kaerlev's Anaconda (github.com/Matt-Esch/anaconda), which is what CTFAK's native `decompressOld()` wraps. """ CLCIDX = [18, 17, 16, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] def _build_bits_base(delta, first): bits = [0] * 30 for i in range(30 - delta): bits[i + delta] = i // delta base = [0] * 30 s = first for i in range(30): base[i] = s s += 1 << bits[i] return bits, base LENGTH_BITS, LENGTH_BASE = _build_bits_base(4, 3) DIST_BITS, DIST_BASE = _build_bits_base(2, 1) LENGTH_BITS[28] = 0 LENGTH_BASE[28] = 258 class _Tree(object): __slots__ = ('table', 'trans') def __init__(self): self.table = [0] * 16 self.trans = [0] * 288 def _build_fixed_trees(): lt = _Tree() lt.table[7] = 24 lt.table[8] = 152 lt.table[9] = 112 trans = [256 + i for i in range(24)] trans += list(range(144)) trans += [280 + i for i in range(8)] trans += [144 + i for i in range(112)] lt.trans = trans dt = _Tree() dt.table[5] = 32 dt.trans = list(range(32)) return lt, dt SLTREE, SDTREE = _build_fixed_trees() def _build_tree(lengths, num): t = _Tree() for i in range(num): t.table[lengths[i]] += 1 t.table[0] = 0 offs = [0] * 16 s = 0 for i in range(16): offs[i] = s s += t.table[i] trans = [0] * max(num, 288) for i in range(num): if lengths[i]: trans[offs[lengths[i]]] = i offs[lengths[i]] += 1 t.trans = trans return t class _D(object): __slots__ = ('src', 'pos', 'tag', 'bitcount', 'out') def __init__(self, src): self.src = src self.pos = 0 self.tag = 0 self.bitcount = 0 self.out = bytearray() def getbit(self): if self.bitcount == 0: self.tag = self.src[self.pos] self.pos += 1 self.bitcount = 8 bit = self.tag & 1 self.tag >>= 1 self.bitcount -= 1 return bit def read_bits(self, num, base=0): val = 0 mask = 1 for _ in range(num): if self.getbit(): val += mask mask <<= 1 return val + base def decode_symbol(self, t): total = 0 cur = 0 length = 0 while True: cur = 2 * cur + self.getbit() length += 1 total += t.table[length] cur -= t.table[length] if cur < 0: break return t.trans[total + cur] def _decode_trees(d): hlit = d.read_bits(5, 257) hdist = d.read_bits(5, 1) hclen = d.read_bits(4, 4) lengths = [0] * (288 + 32) for i in range(hclen): lengths[CLCIDX[i]] = d.read_bits(3, 0) code_tree = _build_tree(lengths, 19) num = 0 while num < hlit + hdist: sym = d.decode_symbol(code_tree) if sym == 16: prev = lengths[num - 1] for _ in range(d.read_bits(2, 3)): lengths[num] = prev num += 1 elif sym == 17: for _ in range(d.read_bits(3, 3)): lengths[num] = 0 num += 1 elif sym == 18: for _ in range(d.read_bits(7, 11)): lengths[num] = 0 num += 1 else: lengths[num] = sym num += 1 lt = _build_tree(lengths, hlit) dt = _build_tree(lengths[hlit:], hdist) return lt, dt def _inflate_block_data(d, lt, dt): out = d.out while True: sym = d.decode_symbol(lt) if sym == 256: return if sym < 256: out.append(sym) else: sym -= 257 length = d.read_bits(LENGTH_BITS[sym], LENGTH_BASE[sym]) dist = d.decode_symbol(dt) offs = d.read_bits(DIST_BITS[dist], DIST_BASE[dist]) start = len(out) - offs for i in range(length): out.append(out[start + i]) def _inflate_uncompressed(d): length = d.src[d.pos] | (d.src[d.pos + 1] << 8) d.pos += 2 d.out += d.src[d.pos:d.pos + length] d.pos += length d.bitcount = 0 def uncompress(src): """Inflate a Clickteam-variant deflate stream. Returns (data, bytes_consumed). """ d = _D(src) while True: btype = d.read_bits(3, 0) bfinal = d.getbit() if btype == 7: _inflate_uncompressed(d) elif btype == 5: _inflate_block_data(d, SLTREE, SDTREE) elif btype == 6: lt, dt = _decode_trees(d) _inflate_block_data(d, lt, dt) else: raise ValueError('bad block type %d at byte %d' % (btype, d.pos)) if bfinal: break return bytes(d.out), d.pos def decompress_chunk(payload): """Chunk payload with `flags == 1`: u32 decompressedSize then the stream.""" size = int.from_bytes(payload[:4], 'little') data, _ = uncompress(payload[4:]) if len(data) != size: raise ValueError('size mismatch: want %d got %d' % (size, len(data))) return data ``` ### 11.2 `pame_extract.py` -- the container Finds the `PAME` block, walks the chunk chain, and decodes the three banks plus the frame and object tables. The module docstring carries the container layout and the chunk id map. The image decoder is the fiddly part: MMF stores pixels in one of five graphic modes (8-bit indexed, RGB555, RGB565, 24-bit BGR), each row padded to a two-byte boundary, with the color plane optionally run-length encoded in a stream that spans row boundaries. Sounds are raw PCM with a WAVE-format header split across the record; `wav_bytes()` reassembles a playable RIFF file. Everything downstream calls `main()`, which returns one dictionary holding the whole file. ```python """ pame_extract.py -- static extractor for `PAME` (Multimedia Fusion 1.5) exes. Written for `POL Lounge V 1.0.exe` (Majin Vegeta, 4 May 2001) but generic. Container --------- The MMF1.5 game data is appended to the Win32 PE as a `PAME` block: "PAME" u16 runtimeVersion u16 runtimeSubversion u32 productVersion u32 productBuild chunk* ( u16 id, u16 flags, u32 size, u8 data[size] ) -- until id 0x7f7f `flags == 1` means the payload is `u32 decompressedSize` followed by a Clickteam-variant DEFLATE stream -- see `tinf.py`. `flags == 0` is raw. Chunk ids seen in POL Lounge ---------------------------- 0x2223 AppHeader 0x2224 AppName 0x2225 AppAuthor 0x2226 AppMenu 0x2228 Extensions 0x2229 FrameItems 0x222b GlobalValues? 0x222e EditorFilename 0x222f TargetFilename 0x2233 (unknown, 640B) 0x3333 Frame (xN) 0x5555 ? 0x6666 ImageBank 0x6668 SoundBank 0x6669 MusicBank 0x7f7f Last Usage: python3 pame_extract.py [path-to-exe] [outdir] """ import json import os import struct import sys import tinf def _default_exe(): """argv[1] wins; then $POL_LOUNGE_EXE; then a path in `exe-path.txt` next to this script; then the exe sitting in the current directory.""" env = os.environ.get('POL_LOUNGE_EXE') if env: return env here = os.path.dirname(os.path.abspath(__file__)) cfg = os.path.join(here, 'exe-path.txt') if os.path.exists(cfg): return open(cfg, encoding='utf-8').read().strip() return 'POL Lounge V 1.0.exe' DEFAULT_EXE = _default_exe() CHUNK_NAMES = { 0x2223: 'AppHeader', 0x2224: 'AppName', 0x2225: 'AppAuthor', 0x2226: 'AppMenu', 0x2227: 'ExtPath', 0x2228: 'Extensions', 0x2229: 'FrameItems', 0x222a: 'GlobalEvents', 0x222b: 'FrameHandles', 0x222c: 'ExtData', 0x222d: 'AdditionalExtensions', 0x222e: 'EditorFilename', 0x222f: 'TargetFilename', 0x2230: 'AppDoc', 0x2231: 'OtherExtension', 0x2232: 'GlobalValues', 0x2233: 'GlobalStrings', 0x2234: 'Extensions2', 0x3333: 'Frame', 0x3334: 'FrameHeader', 0x3335: 'FrameName', 0x3336: 'FramePassword', 0x3337: 'FramePalette', 0x3338: 'ObjectInstances', 0x3339: 'FrameFadeInFrame', 0x333a: 'FrameFadeOutFrame', 0x333b: 'FrameFadeIn', 0x333c: 'FrameFadeOut', 0x333d: 'FrameEvents', 0x333e: 'FramePlayHeader', 0x333f: 'FrameAdditionalItems', 0x4444: 'ObjectHeader', 0x4445: 'ObjectName', 0x4446: 'ObjectProperties', 0x4447: 'ObjectUnknown', 0x4448: 'ObjectEffects', 0x5555: 'TrueEnd?', 0x5556: 'Unknown5556', 0x5557: 'Unknown5557', 0x5558: 'Unknown5558', 0x6666: 'ImageBank', 0x6667: 'FontBank', 0x6668: 'SoundBank', 0x6669: 'MusicBank', 0x7f7f: 'Last', } OBJECT_TYPES = { -7: 'Player', -6: 'Keyboard', -5: 'Create', -4: 'Timer', -3: 'Game', -2: 'Speaker', -1: 'System', 0: 'QuickBackdrop', 1: 'Backdrop', 2: 'Active', 3: 'Text', 4: 'Question', 5: 'Score', 6: 'Lives', 7: 'Counter', 8: 'RTF', 9: 'SubApplication', } class R(object): """Little-endian byte reader.""" def __init__(self, data, pos=0): self.d = data self.p = pos def u8(self): v = self.d[self.p] self.p += 1 return v def i8(self): v = struct.unpack_from('= len(self.d) def find_pame(data): """The real container is the last PAME magic in the file.""" best = None off = 0 while True: i = data.find(b'PAME', off) if i < 0: break # a real header has a plausible chunk right after the 16-byte header if i + 24 < len(data): cid = struct.unpack_from('> 10) & 31) << 3, ((v >> 5) & 31) << 3, (v & 31) << 3) if mode == 7: # RGB565 v = d[p] | (d[p + 1] << 8) return (((v >> 11) & 31) << 3, ((v >> 5) & 63) << 2, (v & 31) << 3) if mode in (2, 3): # palette index return d[p] raise NotImplementedError('graphic mode %d' % mode) PT_SIZE = {2: 1, 3: 1, 4: 3, 6: 2, 7: 2} def _padding(width, psize, unit=2): pad = unit - ((width * psize) % unit) if pad == unit: pad = 0 return -(-pad // psize) def read_rgb(d, width, height, mode): psize = PT_SIZE[mode] pad = _padding(width, psize) px = [] n = 0 for _ in range(height): for _ in range(width): px.append(_pt_read(mode, d, n)) n += psize n += pad * psize return px, n def read_rle(d, width, height, mode): psize = PT_SIZE[mode] pad = _padding(width, psize) px = [] p = 0 pos = 0 while True: cmd = d[p] p += 1 if cmd == 0: break if cmd > 128: cmd -= 128 for _ in range(cmd): if pos % (width + pad) < width: px.append(_pt_read(mode, d, p)) pos += 1 p += psize else: val = _pt_read(mode, d, p) for _ in range(cmd): if pos % (width + pad) < width: px.append(val) pos += 1 p += psize return px, p def read_alpha(d, width, height, position): pad = _padding(width, 1, 4) out = [] n = position for _ in range(height): out.extend(d[n:n + width]) n += width + pad return out IMAGE_FLAG_NAMES = ['RLE', 'RLEW', 'RLET', 'LZX', 'Alpha', 'ACE', 'Mac'] def parse_image_bank(payload): r = R(payload) count = r.u32() items = [] for _ in range(count): handle = r.u32() raw = payload[r.p:] size = int.from_bytes(raw[:4], 'little') body, consumed = tinf.uncompress(raw[4:]) r.p += 4 + consumed if len(body) != size: raise ValueError('image %d size mismatch' % handle) b = R(body) checksum = b.i16() references = b.i32() datasize = b.u32() width = b.i16() height = b.i16() mode = b.u8() flagbits = b.u8() flags = {n: bool(flagbits >> i & 1) for i, n in enumerate(IMAGE_FLAG_NAMES)} xhot = b.i16() yhot = b.i16() actx = b.i16() acty = b.i16() pixdata = body[b.p:] if flags['RLE'] or flags['RLEW'] or flags['RLET']: px, used = read_rle(pixdata, width, height, mode) else: px, used = read_rgb(pixdata, width, height, mode) alpha = None if flags['Alpha']: alpha = read_alpha(pixdata, width, height, used) items.append(dict(handle=handle, width=width, height=height, mode=mode, flags=flags, xhot=xhot, yhot=yhot, actx=actx, acty=acty, checksum=checksum, references=references, datasize=datasize, px=px, alpha=alpha)) return items # -------------------------------------------------------------------------- # sounds / music # -------------------------------------------------------------------------- def parse_sound_bank(payload): r = R(payload) count = r.u32() items = [] for _ in range(count): handle = r.u32() raw = payload[r.p:] size = int.from_bytes(raw[:4], 'little') body, consumed = tinf.uncompress(raw[4:]) r.p += 4 + consumed b = R(body) checksum = b.u16() references = b.u32() datasize = b.u32() flags = b.u32() b.u32() # reserved namelen = b.u32() name = b.read(namelen).split(b'\x00')[0].decode('latin1') fmt = b.u16() channels = b.u16() rate = b.u32() byterate = b.u32() align = b.u16() bits = b.u16() b.u16() chunksize = b.u32() data = b.read(chunksize) items.append(dict(handle=handle, name=name, format=fmt, channels=channels, rate=rate, byterate=byterate, align=align, bits=bits, flags=flags, size=datasize, data=data)) return items def wav_bytes(s): fmt = struct.pack('> 4 & 1): item['id'] = r.u16() chars = [] while True: c = r.u16() if c == 0: break chars.append(chr(c)) item['name'] = ''.join(chars) out.append(item) if flags >> 4 & 1: item['children'] = items() if flags >> 7 & 1: break return out tree = items() accels = [] r.p = start + accel_off for _ in range(accel_size // 8): shift = r.u8() r.u8() key = r.u16() ident = r.u16() r.u16() accels.append(dict(shift=shift, key=key, id=ident)) return tree, accels # -------------------------------------------------------------------------- # frames / objects # -------------------------------------------------------------------------- def parse_object_instances(payload): r = R(payload) out = [] for _ in range(r.u32()): out.append(dict(handle=r.u16(), objectInfo=r.u16(), x=r.i16(), y=r.i16(), parentType=r.i16(), parentHandle=r.i16())) return out def parse_frame_header(payload): r = R(payload) return dict(width=r.u16(), height=r.u16(), background=r.color(), flags=r.u16()) def cstr(b): return b.split(b'\x00')[0].decode('latin1') def parse_frame(payload): frame = {'chunks': []} for cid, flags, data in read_chunks(payload, 0): name = CHUNK_NAMES.get(cid, '0x%04x' % cid) frame['chunks'].append(dict(id=cid, name=name, size=len(data))) if cid == 0x3334: frame.update(parse_frame_header(data)) elif cid == 0x3335: frame['name'] = cstr(data) elif cid == 0x3336: frame['password'] = cstr(data) elif cid == 0x3338: frame['instances'] = parse_object_instances(data) elif cid == 0x3337: frame['paletteRaw'] = data.hex() elif cid == 0x333d: frame['eventsRaw'] = data else: frame.setdefault('raw', {})['0x%04x' % cid] = data return frame def parse_object_header(data): r = R(data) return dict(handle=r.u16(), objectType=r.i16(), flags=r.u16(), reserved=r.u16(), inkEffect=r.u32(), inkEffectParameter=r.u32()) def parse_frame_items(payload): r = R(payload) count = r.u32() items = [] for _ in range(count): obj = {'chunks': []} # each object is its own chunk list, terminated by 0x7f7f sub = read_chunks(payload, r.p) consumed = 0 for cid, flags, data in sub: consumed += 8 + _clen(payload, r.p + consumed) obj['chunks'].append(dict(id=cid, size=len(data))) if cid == 0x4444: obj.update(parse_object_header(data)) elif cid == 0x4445: obj['name'] = cstr(data) elif cid == 0x4446: obj['propertiesRaw'] = data elif cid == 0x4448: obj['effectsRaw'] = data r.p += consumed items.append(obj) return items def _clen(data, pos): return struct.unpack_from(' 1 else DEFAULT_EXE outdir = sys.argv[2] if len(sys.argv) > 2 else os.path.dirname( os.path.abspath(__file__)) data = open(exe, 'rb').read() base = find_pame(data) magic, rv, rsv, pv, pb = struct.unpack_from('<4sHHII', data, base) print('PAME at 0x%x runtime %04x.%04x product %d build %d' % (base, rv, rsv, pv, pb)) chunks = read_chunks(data, base + 16) report = dict(exe=os.path.basename(exe), pameOffset=base, runtimeVersion=rv, productVersion=pv, productBuild=pb, chunks=[]) frames = [] for cid, flags, payload in chunks: name = CHUNK_NAMES.get(cid, 'Unknown_%04x' % cid) report['chunks'].append(dict(id='0x%04x' % cid, name=name, compressed=flags == 1, size=len(payload))) if cid == 0x2223: report['appHeader'] = parse_app_header(payload) elif cid == 0x2224: report['appName'] = cstr(payload) elif cid == 0x2225: report['appAuthor'] = cstr(payload) elif cid == 0x222e: report['editorFilename'] = cstr(payload) elif cid == 0x222f: report['targetFilename'] = cstr(payload) elif cid == 0x2226: tree, accels = parse_menu(payload) report['menu'] = tree report['accelerators'] = accels elif cid == 0x2228: report['extensionsRaw'] = payload.hex() elif cid == 0x2229: report['objects'] = parse_frame_items(payload) elif cid == 0x3333: frames.append(parse_frame(payload)) elif cid == 0x6666: report['_images'] = parse_image_bank(payload) elif cid == 0x6668: report['_sounds'] = parse_sound_bank(payload) elif cid == 0x6669: report['_music'] = parse_music_bank(payload) else: report.setdefault('rawChunks', {})['0x%04x' % cid] = payload.hex() report['frames'] = frames return report, outdir if __name__ == '__main__': rep, outdir = main() print(json.dumps({k: v for k, v in rep.items() if not k.startswith('_') and k not in ('frames', 'objects', 'rawChunks', 'extensionsRaw')}, indent=2)[:4000]) print('frames:', [(f.get('name'), f.get('width'), f.get('height'), len(f.get('instances', []))) for f in rep['frames']]) print('objects:', len(rep.get('objects', []))) print('images:', len(rep.get('_images', []))) print('sounds:', [(s['name'], s['rate'], s['bits'], s['channels'], len(s['data'])) for s in rep.get('_sounds', [])]) print('music:', [(m['name'], len(m['data'])) for m in rep.get('_music', [])]) ``` ### 11.3 `objects_parse.py` -- object properties Each object carries a properties blob whose layout depends on its type. Backdrops are ten bytes; quick backdrops add a shape and fill; everything else is an `ObjectCommon` header of relative offsets pointing at a movement record, an animation table, a counter, a text block, and -- for extension objects -- an opaque data blob that only the extension understands. Those blobs are worth mining anyway. The button captions ("Send", "Connect", "Sign On"), every edit box's exact width and height, the list box's dimensions and the INI object's filename all fall straight out of the first few bytes, which is where section 3's control table comes from. This is also where the 16-bit `QuickBackdrop` correction lives. ```python """Decode every object's ObjectProperties (0x4446) blob + per-frame instances. Object property layouts (MMF1.5) come from mmfparser's `chunkloaders/onepointfive/all.py` + `chunkloaders/objects.pyx`. """ import json import os import pame_extract as P from pame_extract import R HERE = os.path.dirname(os.path.abspath(__file__)) EXTENSIONS = ['Online.cox', 'kcedit.cox', 'kcbutton.cox', 'kclist.cox', 'kcini.cox', 'kcinput.cox', 'Gstrings.cox'] OBSTACLE = {0: 'None', 1: 'Obstacle', 2: 'Platform'} COLLISION = {0: 'Box', 1: 'FinePerPixel'} SHAPES = {1: 'Line', 2: 'Rectangle', 3: 'Ellipse'} FILLS = {0: 'None', 1: 'Solid', 2: 'Gradient', 3: 'Motif'} def parse_backdrop(d): r = R(d) return dict(kind='Backdrop', size=r.u32(), obstacle=OBSTACLE.get(r.i16()), collision=COLLISION.get(r.i16()), image=r.i16()) def parse_shape(r): s = dict(borderSize=r.i16(), borderColor=r.color()) shape = r.i16() fill = r.i16() s['shape'] = SHAPES.get(shape, shape) s['fill'] = FILLS.get(fill, fill) if shape == 0: s['lineFlags'] = r.u16() elif fill == 1: s['color1'] = r.color() elif fill == 2: s['color1'] = r.color() s['color2'] = r.color() s['gradientFlags'] = r.u16() elif fill == 3: s['image'] = r.i16() return s def parse_quick_backdrop(d): # NOTE: mmfparser reads width/height as i32 here (its MMF2 layout, and its # MMF1.5 path bails into an interactive editor instead). In MMF1.5 they are # i16 -- e.g. handle 0 is f4 01 90 01 = 500x400, exactly the frame size. r = R(d) out = dict(kind='QuickBackdrop', size=r.u32(), obstacle=OBSTACLE.get(r.i16()), collision=COLLISION.get(r.i16()), width=r.i16(), height=r.i16()) out['shape'] = parse_shape(r) return out def parse_animation_header(d, pos): r = R(d, pos) start = r.p r.i16() # size count = r.i16() offsets = [r.i16() for _ in range(count)] anims = {} for i, off in enumerate(offsets): if off == 0: continue a = R(d, start + off) astart = a.p doffs = [a.i16() for _ in range(32)] dirs = {} for di, doff in enumerate(doffs): if doff == 0: continue dd = R(d, astart + doff) minspeed = dd.u8() maxspeed = dd.u8() repeat = dd.i16() backto = dd.i16() frames = [dd.i16() for _ in range(dd.i16())] dirs[di] = dict(minSpeed=minspeed, maxSpeed=maxspeed, repeat=repeat, backTo=backto, frames=frames) anims[i] = dirs return anims def parse_text(d, pos): r = R(d, pos) start = r.p r.u32() # size width = r.i16() height = r.i16() n = r.i16() offs = [r.i16() for _ in range(n)] paras = [] for off in offs: p = R(d, start + off) p.u16() # size font = p.u16() color = p.color() flags = p.u16() raw = d[p.p:] s = raw.split(b'\x00')[0].decode('latin1') paras.append(dict(font=font, color=color, flags=flags, text=s)) return dict(width=width, height=height, paragraphs=paras) def parse_counters(d, pos): r = R(d, pos) r.u32() out = dict(width=r.u16(), height=r.u16(), player=r.u16(), displayType=r.u16(), flags=r.i16()) if out['displayType'] in (1, 2): # numbers / animation out['frames'] = [r.i16() for _ in range(r.u16())] elif out['displayType'] in (3, 4): out['shape'] = parse_shape(r) return out def parse_common(d, object_type): r = R(d) start = r.p out = dict(kind='Common', size=r.u16(), checksum=r.u16()) mv = r.i16() an = r.i16() out['version'] = r.i16() ct = r.i16() sys_off = r.i16() out['ocVariable'] = r.u32() out['flags'] = r.u16() end = r.p + 16 quals = [] for _ in range(8): q = r.i16() if q == -1: break quals.append(q) out['qualifiers'] = quals r.p = end ext = r.i16() val = r.i16() out['newFlags'] = r.u16() out['preferences'] = r.u16() out['identifier'] = r.read(4).decode('latin1', 'replace') out['backColor'] = r.color() fade_in = r.i32() fade_out = r.i32() out['offsets'] = dict(movements=mv, animations=an, counter=ct, systemObject=sys_off, extension=ext, values=val, fadeIn=fade_in, fadeOut=fade_out) if mv: m = R(d, start + mv) out['movement'] = dict(player=m.u16(), type=m.u16(), movingAtStart=m.u8()) if an: out['animations'] = parse_animation_header(d, start + an) if ext: e = R(d, start + ext) data_size = e.u16() - 8 e.u16() out['extensionOldFlags'] = e.u16() out['extensionVersion'] = e.u16() out['extensionData'] = d[e.p:e.p + data_size].hex() if data_size > 0 \ else '' out['extensionDataLen'] = max(data_size, 0) if sys_off: if object_type in (3, 4): out['text'] = parse_text(d, start + sys_off) elif object_type in (5, 6, 7): out['counters'] = parse_counters(d, start + sys_off) return out def parse_properties(obj): d = obj.get('propertiesRaw', b'') t = obj.get('objectType') if t == 1: return parse_backdrop(d) if t == 0: return parse_quick_backdrop(d) return parse_common(d, t) def main(): rep, _ = P.main() objs = [] for o in rep['objects']: try: props = parse_properties(o) except Exception as exc: # keep going, note the failure props = {'error': '%s: %s' % (type(exc).__name__, exc)} t = o.get('objectType') objs.append(dict(handle=o.get('handle'), name=o.get('name'), objectType=t, typeName=(EXTENSIONS[t - 32] if t >= 32 else P.OBJECT_TYPES.get(t, '?')), flags=o.get('flags'), props=props)) json.dump(objs, open(os.path.join(HERE, 'objects-full.json'), 'w'), indent=1, default=str) frames = [] by_handle = {o['handle']: o for o in objs} for i, f in enumerate(rep['frames']): insts = [] for inst in f.get('instances', []): o = by_handle.get(inst['objectInfo'], {}) insts.append(dict(inst, objName=o.get('name'), objType=o.get('typeName'))) frames.append(dict(index=i, name=f.get('name'), width=f.get('width'), height=f.get('height'), background=f.get('background'), instances=insts)) json.dump(frames, open(os.path.join(HERE, 'frames-full.json'), 'w'), indent=1, default=str) for f in frames: print('\n=== FRAME %d %dx%d %d instances ===' % (f['index'], f['width'], f['height'], len(f['instances']))) for inst in sorted(f['instances'], key=lambda a: (a['y'], a['x'])): o = by_handle.get(inst['objectInfo'], {}) extra = '' p = o.get('props', {}) if p.get('kind') == 'Backdrop': extra = 'img=%s %s' % (p['image'], p['obstacle']) elif p.get('kind') == 'QuickBackdrop': extra = '%dx%d %s %s' % (p['width'], p['height'], p['shape']['shape'], p['shape'].get('color1')) elif 'text' in p: extra = repr(p['text']['paragraphs'][0]['text'])[:60] elif 'animations' in p: fr = [] for ai, dirs in p['animations'].items(): for di, dd in dirs.items(): fr += dd['frames'] extra = 'frames=%s' % sorted(set(fr)) print(' (%4d,%4d) h%-3d %-22s %-14s %s' % (inst['x'], inst['y'], inst['objectInfo'], str(o.get('name'))[:22], str(o.get('typeName'))[:14], extra)) if __name__ == '__main__': main() ``` ### 11.4 `pe_resources.py` -- PE resources A minimal resource-directory walker: enough to reach string tables, dialogs, menus, accelerators and icons. Used twice -- on the extension DLLs to recover their ACE menus, and on the host executable to recover the Clickteam runtime's own dialogs, including the About box whose copyright line is positioned off the bottom of itself. The dialog parser handles the classic 16-bit `DLGTEMPLATE`; note that each item is 18 bytes and DWORD-aligned, which is the sort of thing that silently shifts every subsequent control if you get it wrong. ```python """Minimal PE resource walker -- used to read the MMF extensions' ACE menus. Clickteam's own objects (kcedit/kcbutton/kclist/kcini) keep their condition/action/expression menus in Windows MENU resources, one popup per kind, in ACE order. Third-party ones (Online, kcinput, Gstrings) keep them as `NN` strings instead -- see `extension-ace.json`. """ import struct import sys RT = {1: 'CURSOR', 2: 'BITMAP', 3: 'ICON', 4: 'MENU', 5: 'DIALOG', 6: 'STRING', 7: 'FONTDIR', 8: 'FONT', 9: 'ACCELERATOR', 10: 'RCDATA', 11: 'MESSAGETABLE', 12: 'GROUP_CURSOR', 14: 'GROUP_ICON', 16: 'VERSION', 24: 'MANIFEST'} def sections(d): pe = struct.unpack_from(' 1: stack.pop() else: break return out def show(path): d = open(path, 'rb').read() print('=' * 70) print(path, len(d), 'bytes') for keys, data in walk(d): t = RT.get(keys[0], keys[0]) if t == 'MENU': print(' MENU %s (%d bytes)' % (keys[1:], len(data))) def pr(items, depth=1): for it in items: print(' ' * depth + '%-4s %s' % (it['id'] if it['id'] is not None else '', it['text'])) if 'items' in it: pr(it['items'], depth + 1) try: pr(parse_menu(data)) except Exception as exc: print(' parse failed:', exc) else: print(' %-12s %s %d bytes' % (t, keys[1:], len(data))) if __name__ == '__main__': for p in sys.argv[1:]: show(p) ``` ### 11.5 `extensions.py` -- the embedded DLLs Carves the seven `.cox` files out of the overlay, reads each one's version resource and ACE menu, and decodes the application icon from both the `PAME` chunk and the PE so the two can be compared. The carving detail that matters: the declared size in each record header runs slightly long, so cut from each `MZ` to the start of the next record instead. ```python """Everything that lives in the PE rather than in the `PAME` game data. Three jobs: 1. **Carve the embedded extension DLLs.** Between the last PE section and the `PAME` magic, an MMF stand-alone build stores every `.cox` extension it uses, whole, as `u32 0x0000222C, u32 size, char name[] NUL, byte dll[]`, terminated by a `0x7f7f` marker. The declared size runs a few bytes long, so carve from each `MZ` to the start of the next record instead of trusting it. 2. **Read each extension's ACE menu.** Every `.cox` keeps its condition, action and expression menu strings in its own PE string table at ids **28000+N**, **28200+N** and **28400+N**, each prefixed with a two-digit code. This is what turns the app's event table from opcode numbers into English. The four Clickteam objects (edit/button/list/ini) predate the convention and carry no ACE strings at all. 3. **Decode the application icon**, both from the `PAME` icon chunk (a raw 32x32 4bpp DIB: 512 bytes of 4-bit pixels bottom-up, then a 128-byte 1-bit AND mask) and from the PE's own icon resources, so the two can be compared. Usage: python3 extensions.py [path-to-exe] [outdir] """ import json import os import re import struct import sys from PIL import Image import pame_extract as P import pe_resources as PR EXT_RECORD = 0x222C # The classic Windows 16-color palette, used by 4bpp icons. VGA16 = [(0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255)] def section_end(d): """First byte after the last PE section -- where the overlay starts.""" pe, optsz, secs = PR.sections(d) return max(raw + rawsz for _, _, _, raw, rawsz in secs) def carve_extensions(d, start, stop): """Yield (name, dll_bytes) for every embedded .cox between start/stop.""" heads = [] for m in re.finditer(struct.pack('> 4) if x % 2 == 0 else (b & 0xF) transparent = (mask[y * 4 + x // 8] >> (7 - (x % 8))) & 1 px[x, 31 - y] = VGA16[idx] + ((0,) if transparent else (255,)) return im def main(): exe = sys.argv[1] if len(sys.argv) > 1 else P.DEFAULT_EXE out = sys.argv[2] if len(sys.argv) > 2 else os.path.dirname( os.path.abspath(__file__)) d = open(exe, 'rb').read() pe = struct.unpack_from(' runtime-resources.txt') if __name__ == '__main__': main() ``` ### 11.6 `events_parse.py` -- the program logic The largest and most useful of these. It decodes the event chunk of each frame -- event groups, conditions, actions, parameters and the expression trees inside them -- and prints the result as something close to what the original editor showed. Three things make the output readable rather than numeric: - the system opcode tables, transcribed from mmfparser into `ace-names.json`; - each extension's own ACE menu from section 11.5, so `Online.cox act#0` becomes `Connect to %s through port %s`; - object handle resolution, so parameters print as `` rather than as an integer. It also resolves `Jump to frame` through the `FrameHandles` table, which is the difference between a coherent flow and an apparently circular one. ```python """Decode the MMF1.5 event tables (chunk 0x333d) of each frame. Byte layout is mmfparser's `chunkloaders/onepointfive/all.py` (Events / EventGroup / Condition / Action / Parameter / Expression). The system condition/action/expression name tables come from mmfparser too. Extension conditions/actions (objectType >= 32) are named only inside the extension's own `.cox`, which we do not have, so those print as `#N` -- the object they act on makes them readable in practice. """ import json import os import sys import pame_extract as P from pame_extract import R HERE = os.path.dirname(os.path.abspath(__file__)) HEADER = b'ER>>' EVENT_COUNT = b'ERes' EVENTGROUP_DATA = b'ERev' END = b'< human-name table (system conditions, # system actions, and the shared extension ACE names), transcribed from # mmfparser's `chunkloaders/{actions,conditions}/names.py`. _ACE = json.load(open(os.path.join(HERE, 'ace-names.json'), encoding='utf-8')) def _intkeys(d): return {int(k): ({int(k2): v2 for k2, v2 in v.items()} if isinstance(v, dict) else v) for k, v in d.items()} COND_SYS = _intkeys(_ACE['conditions']['system']) COND_EXT = _intkeys(_ACE['conditions']['extension']) ACT_SYS = _intkeys(_ACE['actions']['system']) ACT_EXT = _intkeys(_ACE['actions']['extension']) # chunk 0x222b "FrameHandles": the FRAME action parameter is an index into # this table, NOT a frame index (mmfparser player: frameHandles[param.value]). FRAME_HANDLES = [] # `extension-ace.json` -- each .cox's own condition/action/expression menu, # read out of its STRING resources (28000+N / 28200+N / 28400+N). The four # Clickteam objects (edit/button/list/ini) are pre-CC2 and carry no ACE # strings at all; their menus lived in the MMF editor, so they stay numeric. _EXT = json.load(open(os.path.join(HERE, 'extension-ace.json'), encoding='utf-8')) def ext_ace(cox, kind, idx): e = _EXT.get(cox) if not e: return None return e[kind].get(str(idx)) # objectInfo -> .cox name, filled in by main() so expressions can be named EXPR_OBJECTS = {} def read_cstring(d, p): e = d.index(b'\x00', p) return d[p:e].decode('latin1'), e + 1 def parse_expression_list(r): """Read Expressions until the End marker; return a flat token list.""" out = [] while True: start = r.p object_type = r.i8() num = r.u8() if object_type > 2: num += 32 if object_type == 0 and num == 0: out.append('End') break size = r.u16() tok = {'objectType': object_type, 'num': num} name = EXPR_SYSTEM.get(object_type, {}).get(num) payload = r.d[start + 4:start + size] if object_type == -1 and num == 0 and len(payload) >= 4: tok['value'] = int.from_bytes(payload[:4], 'little', signed=True) name = str(tok['value']) elif object_type == -1 and num == 3: tok['value'] = payload.split(b'\x00')[0].decode('latin1') name = '"%s"' % tok['value'] elif object_type >= 2 or object_type == -7: tok['objectInfo'] = int.from_bytes(payload[:2], 'little') tok['objectInfoList'] = int.from_bytes(payload[2:4], 'little', signed=True) rest = payload[4:] ext = None cox = (EXPR_OBJECTS or {}).get(tok['objectInfo']) if cox: real = ext_ace(cox, 'expressions', num - 80) if real: ext = real.rstrip('(') if ext is None: ext = EXPR_EXTENSION.get(num - 80) if name is None: name = 'obj%d.%s' % (tok['objectInfo'], ext or ('ext#%d' % (num - 80))) if rest: txt = rest.split(b'\x00')[0] if txt and all(32 <= c < 127 for c in txt): name += '("%s")' % txt.decode('latin1') elif len(rest) >= 4: name += '(%d)' % int.from_bytes(rest[:4], 'little', signed=True) if name is None: name = 'expr(%d,%d)%s' % (object_type, num, payload.hex()) tok['name'] = name out.append(tok) r.p = start + size return out def parse_parameter(r): start = r.p size = r.u16() code = r.u16() name = PARAM_NAMES.get(code, 'p%d' % code) payload = r.d[start + 4:start + size] val = None if name in ('EXPRESSION', 'EXPSTRING', 'CMPSTRING', 'COMPARISON', 'VARGLOBAL_EXP', 'STRINGGLOBAL_EXP', 'AlterableValueExpression', 'FLAG_EXP', 'ALTSTRING_EXP'): rr = R(r.d, start + 4) comparison = rr.i16() toks = parse_expression_list(rr) val = {'comparison': comparison, 'expr': ' '.join(t if isinstance(t, str) else t['name'] for t in toks[:-1])} elif name in ('STRING', 'FILENAME', 'REMARK'): val = payload.split(b'\x00')[0].decode('latin1') elif name in ('SHORT', 'TEXTNUMBER', 'FRAME', 'MENU', 'GROUPOINTER', 'GlobalValue', 'AlterableValue', 'FLAG', 'GlobalString', 'MVT', '8DIRECTIONS'): val = int.from_bytes(payload[:2], 'little', signed=True) elif name == 'INT': val = int.from_bytes(payload[:4], 'little', signed=True) elif name == 'OBJECT': # i16 objectInfoList, u16 objectInfo, i16 objectType val = {'objectInfoList': int.from_bytes(payload[:2], 'little', signed=True), 'objectInfo': int.from_bytes(payload[2:4], 'little'), 'objectType': int.from_bytes(payload[4:6], 'little', signed=True)} elif name in ('POSITION', 'CREATE', 'SYSCREATE', 'SHOOT'): pos = {'objectInfoParent': int.from_bytes(payload[:2], 'little'), 'flags': int.from_bytes(payload[2:4], 'little'), 'x': int.from_bytes(payload[4:6], 'little', signed=True), 'y': int.from_bytes(payload[6:8], 'little', signed=True), 'direction': int.from_bytes(payload[12:16], 'little', signed=True)} if name == 'POSITION': val = pos else: # Position, then u16 instance, u16 objectInfo val = dict(pos, objectInstance=int.from_bytes(payload[22:24], 'little'), objectInfo=int.from_bytes(payload[24:26], 'little')) elif name == 'Every': val = {'delayMs': int.from_bytes(payload[:4], 'little', signed=True)} elif name == 'Click': val = {'button': payload[0], 'double': bool(payload[1])} elif name == 'TIME': val = int.from_bytes(payload[:4], 'little', signed=True) elif name in ('SAMPLE', 'CNDSAMPLE', 'CNDMUSIC'): # u32 handle, then the sample/music name as a C string val = {'handle': int.from_bytes(payload[:4], 'little'), 'name': payload[4:].split(b'\x00')[0].decode('latin1')} elif name == 'GROUP': val = {'flags': int.from_bytes(payload[:2], 'little'), 'id': int.from_bytes(payload[2:4], 'little'), 'name': payload[4:].split(b'\x00')[0].decode('latin1')} elif name in ('KEY', 'VMKEY'): val = {'vk': int.from_bytes(payload[:2], 'little')} else: val = payload.hex() r.p = start + size return {'code': code, 'name': name, 'value': val} def parse_condition(r): start = r.p size = r.u16() object_type = r.i8() num = r.i8() if object_type > 2: num -= 32 object_info = r.u16() object_info_list = r.i16() flags = r.u8() other_flags = r.u8() nparams = r.u8() def_type = r.u8() identifier = r.i16() params = [parse_parameter(r) for _ in range(nparams)] r.p = start + size return dict(kind='condition', objectType=object_type, num=num, objectInfo=object_info, objectInfoList=object_info_list, flags=flags, otherFlags=other_flags, identifier=identifier, params=params) def parse_action(r): start = r.p size = r.u16() object_type = r.i8() num = r.u8() if object_type > 2: num += 32 object_info = r.u16() object_info_list = r.i16() flags = r.u8() other_flags = r.u8() nparams = r.u8() def_type = r.u8() params = [parse_parameter(r) for _ in range(nparams)] r.p = start + size return dict(kind='action', objectType=object_type, num=num, objectInfo=object_info, objectInfoList=object_info_list, flags=flags, otherFlags=other_flags, params=params) def parse_group(r): start = r.p size = -r.i16() nconds = r.u8() nacts = r.u8() flags = r.u16() restrict = r.i16() restrict_cpt = r.i16() identifier = r.i16() undo = r.i16() conds = [parse_condition(r) for _ in range(nconds)] acts = [parse_action(r) for _ in range(nacts)] r.p = start + size return dict(flags=flags, identifier=identifier, conditions=conds, actions=acts) def parse_events(data): r = R(data) out = {'groups': []} while True: ident = r.read(4) if ident == HEADER: out['maxObjects'] = r.i16() out['maxObjectInfo'] = r.i16() out['numberOfPlayers'] = r.i16() out['numberOfConditions'] = [r.i16() for _ in range(17)] quals = [] for _ in range(r.i16()): quals.append(dict(objectInfo=r.u16(), type=r.i16(), qualifier=r.i16())) out['qualifiers'] = quals elif ident == EVENT_COUNT: out['eventCount'] = r.u32() elif ident == EVENTGROUP_DATA: size = r.u32() end = r.p + size while r.p < end: out['groups'].append(parse_group(r)) elif ident == END: break else: out['error'] = 'unknown block %r at %d' % (ident, r.p - 4) break return out # -------------------------------------------------------------------------- # pretty printing # -------------------------------------------------------------------------- OBJ_TYPE_LABEL = {-7: 'Player', -6: 'Mouse/Keyboard', -5: 'Create', -4: 'Timer', -3: 'Game', -2: 'Speaker', -1: 'System', 0: 'QuickBackdrop', 1: 'Backdrop', 2: 'Active', 3: 'Text', 4: 'Question', 5: 'Score', 6: 'Lives', 7: 'Counter', 8: 'RTF', 9: 'SubApplication'} def ace_name(entry, is_cond, objects): """Name an ACE. Three namespaces: * `objectType < 0` -- system conditions/actions, keyed (type, num). * `0 <= objectType <= 9` -- the *common* object ACEs shared by every built-in object; `num` indexes them directly (negative for conditions, positive for actions). * `objectType >= 32` -- an extension object. Its ACE table lives in the `.cox` we do not have, so print the extension name + its own index, which is `num - 80` for actions and `-num - 81` for conditions. """ t = entry['objectType'] n = entry['num'] sysd = COND_SYS if is_cond else ACT_SYS extd = COND_EXT if is_cond else ACT_EXT if t >= 32: obj = objects.get(entry['objectInfo'], {}) cox = obj.get('typeName', 'ext') idx = (-n - 81) if is_cond else (n - 80) real = ext_ace(cox, 'conditions' if is_cond else 'actions', idx) if real: # the leading %s is the object's own name; the rest take the # event's parameters, which are printed separately return real.split('%s', 1)[-1].lstrip(': ') if real.startswith( '%s') else real return '%s %s#%d' % (cox, 'cond' if is_cond else 'act', idx) if t in sysd and n in sysd[t]: return sysd[t][n] if 0 <= t <= 9 and n in extd: return extd[n] return '%s#%d' % (OBJ_TYPE_LABEL.get(t, 't%d' % t), n) CMP = {0: '=', 1: '!=', 2: '<=', 3: '<', 4: '>=', 5: '>'} def fmt_param(p, objects=None): objects = objects or {} v = p['value'] if isinstance(v, dict) and 'objectInfo' in v: o = objects.get(v['objectInfo'], {}) label = o.get('name') or o.get('typeName') or '?' if p['name'] == 'OBJECT': return '<%s h%d>' % (label, v['objectInfo']) return '<%s h%d> at (%d,%d)' % (label, v['objectInfo'], v.get('x', 0), v.get('y', 0)) if isinstance(v, dict) and set(v) == {'delayMs'}: return '%d ms' % v['delayMs'] if isinstance(v, dict) and 'expr' in v: cmp_ = v['comparison'] return v['expr'] if cmp_ == 0 else '%s %s' % (CMP.get(cmp_, 'cmp%d' % cmp_), v['expr']) if isinstance(v, dict) and 'name' in v and 'handle' in v: return '"%s"' % v['name'] if isinstance(v, dict) and set(v) == {'flags', 'id', 'name'}: return 'group "%s" (id %d)' % (v['name'], v['id']) if isinstance(v, dict) and set(v) == {'vk'}: return 'VK 0x%02x' % v['vk'] return repr(v) def dump(frames, objects, out): for fi, ev in enumerate(frames): out.write('\n' + '=' * 72 + '\nFRAME %d -- %d event groups\n' % (fi, len(ev['groups'])) + '=' * 72 + '\n') for gi, g in enumerate(ev['groups']): out.write('\n[%d.%d]\n' % (fi, gi)) for c in g['conditions']: obj = objects.get(c['objectInfo'], {}) label = ace_name(c, True, objects) if c['otherFlags'] & 1: # ACE_OTHERFLAGS bit 0 = Not label = 'NOT ' + label oname = obj.get('name') or obj.get('typeName') or '' out.write(' IF %-34s %-22s %s\n' % (label, ('<%s>' % oname) if oname else '', ', '.join(fmt_param(p, objects) for p in c['params']))) for a in g['actions']: obj = objects.get(a['objectInfo'], {}) label = ace_name(a, False, objects) oname = obj.get('name') or obj.get('typeName') or '' args = ', '.join(fmt_param(p, objects) for p in a['params']) # "Jump to frame" indexes the FrameHandles table (chunk # 0x222b), it is not a frame index -- resolve it. if label == 'JumpToFrame' and FRAME_HANDLES: idx = a['params'][0]['value'] if isinstance(idx, int) and 0 <= idx < len(FRAME_HANDLES): args = 'handle %d -> FRAME %d' % (idx, FRAME_HANDLES[idx]) out.write(' DO %-34s %-22s %s\n' % (label, ('<%s>' % oname) if oname else '', args)) def main(): rep, _ = P.main() global FRAME_HANDLES raw = rep.get('rawChunks', {}).get('0x222b') if raw: b = bytes.fromhex(raw) FRAME_HANDLES = [int.from_bytes(b[i:i + 2], 'little') for i in range(0, len(b), 2)] import objects_parse global EXPR_OBJECTS EXPR_OBJECTS = {o['handle']: objects_parse.EXTENSIONS[o['objectType'] - 32] for o in rep['objects'] if (o.get('objectType') or 0) >= 32} objects = {} for o in rep['objects']: t = o.get('objectType') objects[o['handle']] = dict( name=o.get('name'), typeName=(objects_parse.EXTENSIONS[t - 32] if t >= 32 else P.OBJECT_TYPES.get(t, '?'))) frames = [] for i, f in enumerate(rep['frames']): frames.append(parse_events(f['eventsRaw'])) json.dump(frames, open(os.path.join(HERE, 'events.json'), 'w'), indent=1, default=str) with open(os.path.join(HERE, 'EVENTS.txt'), 'w', encoding='utf-8') as fh: dump(frames, objects, fh) dump(frames, objects, sys.stdout) if __name__ == '__main__': main() ``` ### 11.7 `render_frames.py` -- compositing and visibility Draws each frame from its instance list -- backdrops in creation order, then quick backdrops with their gradients and motif fills, then actives at their animation's first frame offset by its hotspot -- and reports which images are never reachable. This is the verification step. Frame 0 composites pixel-identical to a screenshot of the running program, which is a strong check that the gradient math, the tile grid, the image decoder and the instance coordinates are all correct simultaneously. ```python """Composite each frame from its instance list, and find unused images.""" import json import os from PIL import Image, ImageDraw import objects_parse as OP import pame_extract as P HERE = os.path.dirname(os.path.abspath(__file__)) def img_rgba(img): w, h = img['width'], img['height'] out = Image.new('RGBA', (w, h)) px = img['px'] alpha = img['alpha'] data = [] for i, p in enumerate(px): if isinstance(p, int): p = (p, p, p) a = alpha[i] if alpha is not None else (0 if p == (0, 0, 0) else 255) data.append((p[0], p[1], p[2], a)) out.putdata(data) return out def grad(w, h, c1, c2, flags): """MMF gradient fill: flags bit 0 set == vertical.""" im = Image.new('RGB', (max(w, 1), max(h, 1))) d = ImageDraw.Draw(im) vertical = bool(flags & 1) n = h if vertical else w for i in range(max(n, 1)): t = i / max(n - 1, 1) c = tuple(int(c1[k] + (c2[k] - c1[k]) * t) for k in range(3)) if vertical: d.line([(0, i), (w, i)], fill=c) else: d.line([(i, 0), (i, h)], fill=c) return im.convert('RGBA') def main(): rep, _ = P.main() images = {i['handle']: i for i in rep['_images']} cache = {} def get(handle): if handle not in cache: cache[handle] = img_rgba(images[handle]) return cache[handle] objs = {} for o in rep['objects']: t = o.get('objectType') try: props = OP.parse_properties(o) except Exception: props = {} objs[o['handle']] = dict(name=o.get('name'), objectType=t, props=props) used = set() os.makedirs(os.path.join(HERE, 'render'), exist_ok=True) for fi, f in enumerate(rep['frames']): w, h = f['width'], f['height'] canvas = Image.new('RGBA', (w, h), tuple(f['background']) + (255,)) # Backdrops first (MMF draws backdrop-layer objects behind actives), # then everything else in instance order. order = sorted(f['instances'], key=lambda i: 0 if objs.get(i['objectInfo'], {}) .get('objectType') in (0, 1) else 1) for inst in order: o = objs.get(inst['objectInfo']) if not o: continue p = o['props'] x, y = inst['x'], inst['y'] if p.get('kind') == 'Backdrop': used.add(p['image']) canvas.alpha_composite(get(p['image']), (x, y)) if \ 0 <= x < w and 0 <= y < h else canvas.paste( get(p['image']), (x, y), get(p['image'])) elif p.get('kind') == 'QuickBackdrop': s = p['shape'] bw, bh = p['width'], p['height'] if s['fill'] == 'Gradient': tile = grad(bw, bh, s['color1'], s['color2'], s.get('gradientFlags', 0)) elif s['fill'] == 'Solid': tile = Image.new('RGBA', (max(bw, 1), max(bh, 1)), tuple(s['color1']) + (255,)) elif s['fill'] == 'Motif': used.add(s['image']) src = get(s['image']) tile = Image.new('RGBA', (max(bw, 1), max(bh, 1))) for ty in range(0, bh, src.height): for tx in range(0, bw, src.width): tile.alpha_composite(src, (tx, ty)) else: continue canvas.paste(tile, (x, y), tile) elif 'animations' in p: anims = p['animations'] frames = [] for ai in sorted(anims): for di in sorted(anims[ai]): frames += anims[ai][di]['frames'] used.update(frames) if frames: src = get(frames[0]) im = images[frames[0]] canvas.alpha_composite( src, (max(x - im['xhot'], 0), max(y - im['yhot'], 0))) elif 'counters' in p and p['counters'].get('frames'): used.update(p['counters']['frames']) canvas.convert('RGB').save(os.path.join(HERE, 'render', 'frame%d.png' % fi)) print('rendered frame%d.png (%dx%d)' % (fi, w, h)) all_handles = set(images) unused = sorted(all_handles - used) print('\nimages total %d, referenced %d, UNUSED %d' % (len(all_handles), len(used), len(unused))) for u in unused: im = images[u] print(' handle %-3d %3dx%-3d refs=%d' % (u, im['width'], im['height'], im['references'])) json.dump(dict(used=sorted(used), unused=unused), open(os.path.join(HERE, 'image-usage.json'), 'w'), indent=1) # contact sheet of every image, labeled cols = 8 cell = 72 rows = -(-len(images) // cols) sheet = Image.new('RGB', (cols * cell, rows * (cell + 12)), (32, 32, 32)) d = ImageDraw.Draw(sheet) for n, hnd in enumerate(sorted(images)): src = get(hnd) s = src.copy() s.thumbnail((cell - 4, cell - 4)) cx = (n % cols) * cell + 2 cy = (n // cols) * (cell + 12) + 2 bg = Image.new('RGBA', s.size, (200, 200, 200, 255)) bg.alpha_composite(s) sheet.paste(bg.convert('RGB'), (cx, cy)) d.text((cx, cy + cell - 2), '%d%s' % (hnd, '' if hnd in used else ' X'), fill=(255, 220, 120) if hnd not in used else (200, 255, 200)) sheet.save(os.path.join(HERE, 'contact-sheet.png')) print('\ncontact-sheet.png written') if __name__ == '__main__': main() ``` ### 11.8 `dump.py` -- writing everything out Straightforward: every image to PNG twice (color-keyed and raw), every sound to WAV, the music to `.mid`, every undecoded chunk to `.bin`, and JSON sidecars for the metadata. ```python """Dump every asset + structural fact out of POL Lounge V 1.0.exe.""" import json import os import struct import sys from PIL import Image import pame_extract as P HERE = os.path.dirname(os.path.abspath(__file__)) def save_png(img, path): w, h = img['width'], img['height'] out = Image.new('RGBA', (w, h)) px = img['px'] alpha = img['alpha'] data = [] # MMF1.5 images have no transparent color field; (0,0,0) is the key for i, p in enumerate(px): if isinstance(p, int): p = (p, p, p) a = 255 if alpha is not None: a = alpha[i] elif p == (0, 0, 0): a = 0 data.append((p[0], p[1], p[2], a)) out.putdata(data) out.save(path) def save_png_opaque(img, path): w, h = img['width'], img['height'] out = Image.new('RGB', (w, h)) out.putdata([p if not isinstance(p, int) else (p, p, p) for p in img['px']]) out.save(path) def main(): rep, _ = P.main() os.makedirs(os.path.join(HERE, 'images'), exist_ok=True) os.makedirs(os.path.join(HERE, 'images_opaque'), exist_ok=True) os.makedirs(os.path.join(HERE, 'sounds'), exist_ok=True) imeta = [] for img in rep['_images']: n = '%03d' % img['handle'] save_png(img, os.path.join(HERE, 'images', n + '.png')) save_png_opaque(img, os.path.join(HERE, 'images_opaque', n + '.png')) imeta.append({k: img[k] for k in ('handle', 'width', 'height', 'mode', 'xhot', 'yhot', 'actx', 'acty', 'references')} | {'flags': [k for k, v in img['flags'].items() if v]}) json.dump(imeta, open(os.path.join(HERE, 'images.json'), 'w'), indent=1) smeta = [] for s in rep['_sounds']: p = os.path.join(HERE, 'sounds', s['name'] + '.wav') open(p, 'wb').write(P.wav_bytes(s)) smeta.append({k: s[k] for k in ('handle', 'name', 'format', 'channels', 'rate', 'byterate', 'align', 'bits', 'flags', 'size')}) for m in rep['_music']: ext = '.mid' if m['data'][:4] == b'MThd' else '.bin' p = os.path.join(HERE, 'sounds', m['name'] + ext) open(p, 'wb').write(m['data']) smeta.append(dict(handle=m['handle'], name=m['name'], kind='music', bytes=len(m['data']), magic=m['data'][:4].decode('latin1', 'replace'))) json.dump(smeta, open(os.path.join(HERE, 'sounds.json'), 'w'), indent=1) objs = [] for o in rep['objects']: objs.append(dict(handle=o.get('handle'), name=o.get('name'), objectType=o.get('objectType'), typeName=P.OBJECT_TYPES.get(o.get('objectType'), '?'), flags=o.get('flags'), inkEffect=o.get('inkEffect'), propSize=len(o.get('propertiesRaw', b'')), chunks=['0x%04x' % c['id'] for c in o['chunks']])) json.dump(objs, open(os.path.join(HERE, 'objects.json'), 'w'), indent=1) frames = [] for i, f in enumerate(rep['frames']): fr = dict(index=i, name=f.get('name'), width=f.get('width'), height=f.get('height'), background=f.get('background'), flags=f.get('flags'), chunks=[(c['name'], c['size']) for c in f['chunks']], instances=f.get('instances', [])) ev = f.get('eventsRaw') if ev: open(os.path.join(HERE, 'frame%d_events.bin' % i), 'wb').write(ev) fr['eventsBytes'] = len(ev) for k, v in (f.get('raw') or {}).items(): open(os.path.join(HERE, 'frame%d_%s.bin' % (i, k)), 'wb').write(v) frames.append(fr) json.dump(frames, open(os.path.join(HERE, 'frames.json'), 'w'), indent=1) for k, v in (rep.get('rawChunks') or {}).items(): open(os.path.join(HERE, 'chunk_%s.bin' % k), 'wb').write( bytes.fromhex(v)) if rep.get('extensionsRaw'): open(os.path.join(HERE, 'chunk_0x2228.bin'), 'wb').write( bytes.fromhex(rep['extensionsRaw'])) json.dump({k: v for k, v in rep.items() if not k.startswith('_') and k not in ('frames', 'objects', 'rawChunks')}, open(os.path.join(HERE, 'app.json'), 'w'), indent=1, default=str) print('images ', len(imeta)) print('sounds ', [s['name'] for s in smeta]) print('objects ', len(objs)) for f in frames: print('frame %d %-12s %dx%d bg=%s instances=%d chunks=%s' % (f['index'], f['name'], f['width'], f['height'], f['background'], len(f['instances']), f['chunks'])) if __name__ == '__main__': main() ``` ### 11.9 `midi_info.py` -- MIDI credits Small, and the reason the music turned out to be what it is. Sequencers of the era routinely left their name, their email and the original composer's credit in track text events, and MMF copies the file in byte for byte. Run this on anything you pull out of a music bank before assuming it is the application's own music. ```python """Read a Standard MIDI File's header, tempo, length and text meta-events. Worth running on anything you pull out of a music bank before assuming it is the app's own music: sequencers of the era routinely left their name, their e-mail and the original composer's credit in track text events, and MMF copies the file in byte for byte. That is how the POL Lounge music turned out to be a fan transcription of a Chrono Cross track rather than something Majin Vegeta wrote. Usage: python3 midi_info.py file.mid """ import struct import sys META = {0x01: 'text', 0x02: 'copyright', 0x03: 'track name', 0x04: 'instrument', 0x05: 'lyric', 0x06: 'marker', 0x07: 'cue point', 0x51: 'tempo', 0x58: 'time signature', 0x59: 'key signature'} def varlen(d, i): v = 0 while True: b = d[i] i += 1 v = (v << 7) | (b & 0x7f) if not b & 0x80: return v, i def read(path): d = open(path, 'rb').read() if d[:4] != b'MThd': raise ValueError('not a Standard MIDI File') fmt, ntrk, div = struct.unpack('>HHH', d[8:14]) print('%s: format %d, %d tracks, division %d, %d bytes' % (path, fmt, ntrk, div, len(d))) p = 14 tempos = [] total = 0 for t in range(ntrk): assert d[p:p + 4] == b'MTrk' ln = struct.unpack('>I', d[p + 4:p + 8])[0] trk = d[p + 8:p + 8 + ln] i = tick = 0 running = 0 while i < len(trk): dt, i = varlen(trk, i) tick += dt st = trk[i] if st == 0xFF: # meta event mt = trk[i + 1] i += 2 ln2, i = varlen(trk, i) data = trk[i:i + ln2] i += ln2 if mt in (0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07): print(' track %-2d %-12s %s' % (t, META[mt], data.decode('latin1', 'replace'))) elif mt == 0x51 and ln2 == 3: tempos.append((tick, int.from_bytes(data, 'big'))) elif st in (0xF0, 0xF7): # sysex i += 1 ln2, i = varlen(trk, i) i += ln2 else: # channel voice if st & 0x80: running = st i += 1 i += 1 if (running & 0xF0) in (0xC0, 0xD0) else 2 total = max(total, tick) p += 8 + ln us = tempos[0][1] if tempos else 500000 print(' %d ticks, %.2f BPM, %.1f s%s' % (total, 60000000 / us, total / div * us / 1e6, '' if len(tempos) < 2 else ' (%d tempo changes)' % len(tempos))) if __name__ == '__main__': for f in sys.argv[1:]: read(f) ``` --- ## 12. About this teardown Produced by **FieRcE YeD**, 11 August 2026, for **[doors98.com](https://doors98.com)**. ### Why bother POL Lounge cannot be run. Not "runs badly on modern Windows" -- it genuinely cannot do anything, because everything it does requires a server that went away around 2003, and its own event table's answer to a failed connection is to show an error and close itself. Two of its three screens have not been seen by a human being in roughly twenty years, and one of them is an entire 800x600 hand-built overworld. So there were two options: let it stay a 590 KB file nobody can open, or read it. Reading it turned out to recover a walk-around Pokemon chat lobby, a Chrono Cross MIDI with its sequencer's GeoCities address still attached, four pieces of scenery dragged off the edge of the map and forgotten, a Send button wired to nothing, and seven complete third-party DLLs from 1996-2000 sitting inside the file like fossils in rock. ### Why a teardown and not a description The compression in section 10.2 is the reason MMF 1.5 files have stayed largely unexamined: standard tools reject every compressed chunk, so most people conclude the file is encrypted and stop. It is not encrypted. It is deflate with four small deliberate differences, and once you know them the whole format opens up -- not just this file, but every application ever built with that runtime. That is why section 11 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. Standing on Mathias Kaerlev's **Anaconda / mmfparser**, which is where the MMF 1.5 structures were documented in the first place, this fills in the two pieces it did not cover: the compression as Clickteam actually shipped it, and the extension DLLs embedded in the executable that make the event tables legible. ### 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 of several recovered applications from this same scene and era: the POL Map Editor, Pokemon Online Advanced Beta 1.0, and Epic's Memorial. Where things live: | | | |---|---| | The original executable | | | This document, raw | | | This document, as Markdown | | | This document, on the desktop | | | The download entry | | | Other recovered POL-era software | | (The two 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. 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 Everything here was derived from one file, and some of it is inference -- anything uncertain is marked as such in the text, particularly the duplicate-avatar question in section 7 and the sprite facing in section 4.6, which follows MMF's direction convention rather than observation. If you were part of the POL scene, wrote one of the extensions, know what happened to the iCLICKZONE MOO network, or are Majin Vegeta: corrections are genuinely welcome. So are takedown requests from any rights holder named in section 9 -- see the notes there.