POL Lounge V 1.0 — complete teardown
A complete static teardown of POL Lounge V 1.0 (2001): the Clickteam PAME container and its non-standard deflate, all 53 images, the audio, the seven embedded extension DLLs, the full event logic, the hidden content, the bugs, and the extraction toolchain.
From the desktop window
pol-lounge-readme.txt — POL Lounge V 1.0 teardown
# 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
**<https://archive.org/details/doors98-pol-lounge-1.0>**, 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, <whole DLL from MZ>`. 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('<b', self.d, self.p)[0]
self.p += 1
return v
def u16(self):
v = struct.unpack_from('<H', self.d, self.p)[0]
self.p += 2
return v
def i16(self):
v = struct.unpack_from('<h', self.d, self.p)[0]
self.p += 2
return v
def u32(self):
v = struct.unpack_from('<I', self.d, self.p)[0]
self.p += 4
return v
def i32(self):
v = struct.unpack_from('<i', self.d, self.p)[0]
self.p += 4
return v
def read(self, n):
v = self.d[self.p:self.p + n]
self.p += n
return v
def color(self):
b = self.read(4)
return (b[0], b[1], b[2])
def rest(self):
return self.d[self.p:]
def eof(self):
return self.p >= 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('<H', data, i + 16)[0]
if cid == 0x2223:
best = i
off = i + 1
if best is None:
raise ValueError('no PAME container found')
return best
def read_chunks(data, pos, end=None):
"""Yield (id, flags, payload) until 0x7f7f or end."""
out = []
n = len(data) if end is None else end
while pos + 8 <= n:
cid, flags, size = struct.unpack_from('<HHI', data, pos)
payload = data[pos + 8:pos + 8 + size]
if flags == 1:
payload = tinf.decompress_chunk(payload)
out.append((cid, flags, payload))
pos += 8 + size
if cid == 0x7f7f:
break
return out
# --------------------------------------------------------------------------
# images
# --------------------------------------------------------------------------
def _pt_read(mode, d, p):
if mode == 4: # 24-bit BGR
return (d[p + 2], d[p + 1], d[p])
if mode == 6: # RGB555
v = d[p] | (d[p + 1] << 8)
return (((v >> 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('<HHIIHH', s['format'], s['channels'], s['rate'],
s['byterate'], s['align'], s['bits'])
body = b'WAVEfmt ' + struct.pack('<I', len(fmt)) + fmt
body += b'data' + struct.pack('<I', len(s['data'])) + s['data']
return b'RIFF' + struct.pack('<I', len(body)) + body
def parse_music_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()
namelen = b.u32()
name = b.read(namelen).split(b'\x00')[0].decode('latin1')
data = b.rest()
items.append(dict(handle=handle, name=name, flags=flags,
size=datasize, data=data))
return items
# --------------------------------------------------------------------------
# menu
# --------------------------------------------------------------------------
def parse_menu(payload):
r = R(payload)
start = r.p
r.u32() # header size
menu_off = r.i32()
menu_size = r.i32()
if menu_size == 0:
return [], []
accel_off = r.i32()
accel_size = r.i32()
r.p = start + menu_off + 4
def items():
out = []
while True:
flags = r.u16()
item = {'flags': flags}
if not (flags >> 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('<I', data, pos + 4)[0]
# --------------------------------------------------------------------------
# app header
# --------------------------------------------------------------------------
def parse_app_header(payload):
r = R(payload)
flags = r.u16()
new_flags = r.u16()
mode = r.i16()
other_flags = r.u16()
width = r.u16()
height = r.u16()
score = r.u32() ^ 0xffffffff
lives = r.u32() ^ 0xffffffff
controls = [r.i16() for _ in range(4)]
keys = [[r.i16() for _ in range(6)] for _ in range(4)]
border = r.color()
frames = r.u32()
return dict(flags=flags, newFlags=new_flags, mode=mode,
otherFlags=other_flags, windowWidth=width,
windowHeight=height, initialScore=score,
initialLives=lives, controls=controls, keys=keys,
borderColor=border, numberOfFrames=frames)
def main():
exe = sys.argv[1] if len(sys.argv) > 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<text>` 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('<I', d, 0x3c)[0]
nsec = struct.unpack_from('<H', d, pe + 6)[0]
optsz = struct.unpack_from('<H', d, pe + 20)[0]
out = []
for i in range(nsec):
o = pe + 24 + optsz + i * 40
name, vsz, va, rawsz, raw = struct.unpack_from('<8sIIII', d, o)
out.append((name.rstrip(b'\0').decode(), va, vsz, raw, rawsz))
return pe, optsz, out
def rva2off(secs, rva):
for _, va, vsz, raw, rawsz in secs:
if va <= rva < va + max(vsz, rawsz):
return raw + (rva - va)
return None
def walk(d):
pe, optsz, secs = sections(d)
ddir = pe + 24 + 96
rsrc_rva, rsrc_sz = struct.unpack_from('<II', d, ddir + 2 * 8)
if not rsrc_rva:
return []
base = rva2off(secs, rsrc_rva)
found = []
def name_at(off):
n = struct.unpack_from('<H', d, base + off)[0]
return d[base + off + 2:base + off + 2 + n * 2].decode('utf-16le')
def dir_(off, path):
nnamed, nid = struct.unpack_from('<HH', d, base + off + 12)
for i in range(nnamed + nid):
e = base + off + 16 + i * 8
nameid, entry = struct.unpack_from('<II', d, e)
key = (name_at(nameid & 0x7fffffff) if nameid & 0x80000000
else nameid)
if entry & 0x80000000:
dir_(entry & 0x7fffffff, path + [key])
else:
drva, dsize = struct.unpack_from('<II', d, base + entry)
o = rva2off(secs, drva)
found.append((path + [key], d[o:o + dsize]))
dir_(0, [])
return found
def parse_menu(data):
"""MENUHEADER + MENUITEM tree (classic, not MENUEX)."""
p = 4
out = []
stack = [out]
while p < len(data):
flags = struct.unpack_from('<H', data, p)[0]
p += 2
if flags & 0x10: # MF_POPUP: no id
ident = None
else:
ident = struct.unpack_from('<H', data, p)[0]
p += 2
chars = []
while p + 1 < len(data):
c = struct.unpack_from('<H', data, p)[0]
p += 2
if c == 0:
break
chars.append(chr(c))
text = ''.join(chars)
node = {'text': text, 'id': ident, 'flags': flags}
stack[-1].append(node)
if flags & 0x10:
node['items'] = []
stack.append(node['items'])
if flags & 0x80: # MF_END
if len(stack) > 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('<I', EXT_RECORD), d[start:stop]):
p = start + m.start()
n = d.find(b'\x00', p + 8)
name = d[p + 8:n]
if 0 < len(name) < 32 and name.endswith(b'.cox') \
and d[n + 1:n + 3] == b'MZ':
heads.append((p, name.decode('latin1'), n + 1))
out = []
for i, (p, name, body) in enumerate(heads):
end = heads[i + 1][0] if i + 1 < len(heads) else stop
out.append((name, d[body:end]))
return out
def pe_strings(dll):
"""{id: text} from a PE's STRING resources."""
out = {}
for keys, data in PR.walk(dll):
if PR.RT.get(keys[0]) != 'STRING':
continue
base = (keys[1] - 1) * 16
p = i = 0
while p + 2 <= len(data):
n = struct.unpack_from('<H', data, p)[0]
p += 2
s = data[p:p + n * 2].decode('utf-16le')
p += n * 2
if s:
out[base + i] = s
i += 1
return out
def ace_table(dll):
"""Pull the extension's own condition/action/expression menu."""
s = pe_strings(dll)
e = {'name': s.get(2), 'description': s.get(3),
'conditions': {}, 'actions': {}, 'expressions': {}}
for k, v in s.items():
if 28000 <= k < 28100:
e['conditions'][k - 28000] = v[2:] # strip the 2-digit code
elif 28200 <= k < 28300:
e['actions'][k - 28200] = v[2:]
elif 28400 <= k < 28500:
e['expressions'][k - 28400] = v
return e
def version_info(dll):
"""The handful of fields that identify who wrote a DLL."""
out = {}
for keys, data in PR.walk(dll):
if PR.RT.get(keys[0]) != 'VERSION':
continue
parts = [m.group().decode('utf-16le')
for m in re.finditer(rb'(?:[ -~]\x00){3,}', data)]
keysWanted = ('CompanyName', 'FileDescription', 'FileVersion',
'InternalName', 'LegalCopyright', 'OriginalFilename')
for i, p in enumerate(parts):
if p in keysWanted and i + 1 < len(parts):
out.setdefault(p, parts[i + 1])
return out
def pe_timestamp(dll):
pe = struct.unpack_from('<I', dll, 0x3c)[0]
return struct.unpack_from('<I', dll, pe + 8)[0]
def decode_icon_chunk(payload):
"""32x32 4bpp DIB, bottom-up, followed by a 1bpp AND mask."""
xor, mask = payload[:512], payload[512:640]
im = Image.new('RGBA', (32, 32), (0, 0, 0, 0))
px = im.load()
for y in range(32):
for x in range(32):
b = xor[y * 16 + x // 2]
idx = (b >> 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('<I', d, 0x3c)[0]
ts = struct.unpack_from('<I', d, pe + 8)[0]
import datetime
print('host PE timestamp %s UTC' % datetime.datetime.fromtimestamp(
ts, datetime.UTC))
for k, v in version_info(d).items():
print(' %-18s %s' % (k, v))
start, stop = section_end(d), P.find_pame(d)
print('\noverlay 0x%x..0x%x (%d bytes)' % (start, stop, stop - start))
os.makedirs(os.path.join(out, 'extensions'), exist_ok=True)
table = {}
for name, dll in carve_extensions(d, start, stop):
open(os.path.join(out, 'extensions', name), 'wb').write(dll)
vi = version_info(dll)
ace = ace_table(dll)
table[name] = dict(ace, version=vi,
timestamp=datetime.datetime.fromtimestamp(
pe_timestamp(dll), datetime.UTC).isoformat())
print('\n%-14s %7d bytes built %s' % (
name, len(dll),
datetime.datetime.fromtimestamp(pe_timestamp(dll),
datetime.UTC).date()))
print(' %s / %s' % (vi.get('FileDescription', '?'),
vi.get('CompanyName', '?')))
for kind in ('conditions', 'actions', 'expressions'):
if ace[kind]:
print(' %s:' % kind)
for i in sorted(ace[kind]):
print(' %2d %s' % (i, ace[kind][i]))
json.dump({k: {kk: ({str(a): b for a, b in vv.items()}
if isinstance(vv, dict) and kk != 'version' else vv)
for kk, vv in v.items()} for k, v in table.items()},
open(os.path.join(out, 'extension-ace.json'), 'w'), indent=1)
# icon, from the PAME chunk and from the PE, for comparison
os.makedirs(os.path.join(out, 'icon'), exist_ok=True)
for cid, flags, payload in P.read_chunks(d, P.find_pame(d) + 16):
if cid == 0x2233 and len(payload) == 640:
decode_icon_chunk(payload).save(
os.path.join(out, 'icon', 'appicon-chunk.png'))
print('\nicon chunk 0x2233 decoded (32x32 4bpp)')
for keys, data in PR.walk(d):
if PR.RT.get(keys[0]) == 'ICON':
open(os.path.join(out, 'icon', 'pe-icon-%s.bin' % keys[1]),
'wb').write(data)
# the runtime's own dialogs and strings
with open(os.path.join(out, 'runtime-resources.txt'), 'w',
encoding='utf-8') as fh:
for keys, data in PR.walk(d):
t = PR.RT.get(keys[0], keys[0])
if t == 'STRING':
base = (keys[1] - 1) * 16
p = i = 0
while p + 2 <= len(data):
n = struct.unpack_from('<H', data, p)[0]
p += 2
s = data[p:p + n * 2].decode('utf-16le')
p += n * 2
if s:
fh.write('STRING %5d %r\n' % (base + i, s))
i += 1
print('runtime resources -> 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 `<POL MAN h10>` 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
`<ext.cox>#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'<<ER'
# Parameter type names, transcribed verbatim from mmfparser's
# `chunkloaders/parameters/names.py` so they cross-reference cleanly against
# that project -- including its spellings.
PARAM_NAMES = {
1: 'OBJECT', 2: 'TIME', 3: 'SHORT', 4: 'SHORT', 5: 'INT', 6: 'SAMPLE',
7: 'SAMPLE', 9: 'CREATE', 10: 'SHORT', 11: 'SHORT', 12: 'SHORT',
13: 'Every', 14: 'KEY', 15: 'EXPRESSION', 16: 'POSITION',
17: 'JOYDIRECTION', 18: 'SHOOT', 19: 'ZONE', 21: 'SYSCREATE',
22: 'EXPRESSION', 23: 'COMPARISON', 24: 'COLOUR', 25: 'BUFFER4',
26: 'FRAME', 27: 'SAMLOOP', 28: 'MUSLOOP', 29: 'NEWDIRECTION',
31: 'TEXTNUMBER', 32: 'Click', 33: 'PROGRAM', 34: 'OLDPARAM_VARGLO',
35: 'CNDSAMPLE', 36: 'CNDMUSIC', 37: 'REMARK', 38: 'GROUP',
39: 'GROUPOINTER', 40: 'FILENAME', 41: 'STRING', 42: 'CMPTIME',
43: 'PASTE', 44: 'VMKEY', 45: 'EXPSTRING', 46: 'CMPSTRING',
47: 'INKEFFECT', 48: 'MENU', 49: 'GlobalValue', 50: 'AlterableValue',
51: 'FLAG', 52: 'VARGLOBAL_EXP', 53: 'AlterableValueExpression',
54: 'FLAG_EXP', 55: 'EXTENSION', 56: '8DIRECTIONS', 57: 'MVT',
58: 'GlobalString', 59: 'STRINGGLOBAL_EXP', 60: 'PROGRAM2',
61: 'ALTSTRING', 62: 'ALTSTRING_EXP', 63: 'FILENAME',
64: 'FASTLOOPNAME',
}
EXPR_SYSTEM = {
0: {0: 'End', 2: '+', 4: '-', 6: '*', 8: '/', 10: '%', 12: '^',
14: '&', 16: '|', 18: '~'},
-1: {0: 'Long', 1: 'Random', 2: 'GlobalValueExpression', 3: 'String',
4: 'ToString', 5: 'ToNumber', 6: 'ApplicationDrive',
7: 'ApplicationDirectory', 8: 'ApplicationPath',
9: 'ApplicationFilename', 10: 'Sin', 11: 'Cos', 12: 'Tan',
13: 'SquareRoot', 14: 'Log', 15: 'Ln', 16: 'Hex', 17: 'Bin',
18: 'Exp', 19: 'LeftString', 20: 'RightString', 21: 'MidString',
22: 'StringLength', 23: 'Double', 24: 'GlobalValue', 28: 'ToInt',
29: 'Abs', 30: 'Ceil', 31: 'Floor', 35: 'Not', 40: 'Min',
41: 'Max', 46: 'LoopIndex', 47: 'NewLine', 48: 'Round',
49: 'GlobalStringExpression', 50: 'GlobalString',
51: 'LowerString', 52: 'UpperString', 53: 'Find',
54: 'ReverseFind', 55: 'GetClipboard',
-1: '(', -2: ')', -3: ','},
-3: {0: 'CurrentFrameOld', 1: 'PlayerCount', 2: 'XLeftFrame',
3: 'XRightFrame', 4: 'YTopFrame', 5: 'YBottomFrame',
6: 'FrameWidth', 7: 'FrameHeight', 8: 'CurrentFrame',
10: 'FrameRate'},
-4: {0: 'TimerValue', 1: 'TimerHundreds', 2: 'TimerSeconds',
3: 'TimerHours', 4: 'TimerMinutes'},
-6: {0: 'XMouse', 1: 'YMouse'},
-7: {0: 'PlayerScore', 1: 'PlayerLives', 2: 'PlayerInputDevice',
3: 'PlayerKeyName', 4: 'PlayerName'},
2: {80: 'GetColorAt', 81: 'GetXScale', 82: 'GetYScale', 83: 'GetAngle'},
3: {80: 'CurrentParagraphIndex', 81: 'CurrentText', 82: 'GetParagraph',
83: 'TextAsNumber', 84: 'ParagraphCount'},
7: {80: 'CounterValue', 81: 'CounterMinimumValue',
82: 'CounterMaximumValue'},
}
EXPR_EXTENSION = {
0: 'XPosition', 1: 'YPosition', 2: 'AnimationFrame', 3: 'Speed',
16: 'ExtensionValue', 19: 'ExtensionString',
}
# `ace-names.json` is the MMF opcode -> 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 | <https://archive.org/details/doors98-pol-lounge-1.0> |
| This document, raw | <https://doors98.com/apps/pol/lounge/pol-lounge-readme.txt> |
| This document, as Markdown | <https://doors98.com/apps/pol/lounge/pol-lounge-readme.md> |
| This document, on the desktop | <https://doors98.com/downloads/pol-lounge-readme> |
| The download entry | <https://doors98.com/downloads/pol-lounge> |
| Other recovered POL-era software | <https://archive.org/details/@fierceyed> |
(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.
A complete static teardown of POL Lounge V 1.0 (Majin Vegeta, 4 May 2001), a Clickteam Multimedia Fusion 1.5 Pokemon Online fan chat client. Produced without running the program: the container format, all 53 images, the audio, the seven extension DLLs embedded in the executable, and the entire event logic were decoded from the bytes.
POL Lounge cannot be run: everything it does requires a server that went away around 2003, and its own event table answers a failed connection by showing an error and closing itself. Two of its three screens had not been seen in roughly twenty years. This teardown reconstructs them from the file. The obstacle was compression: chunks flagged as compressed are neither zlib nor RFC 1951 deflate, because Clickteam shipped a modified copy of Joergen Ibsen's tinflate in which the block type is three bits read before the final-block flag (7 = stored, 5 = fixed, 6 = dynamic), stored blocks carry a length with no complement word, and the code-length ordering is 18,17,16,0,1,2..15. Once decoded, the file yields a walk-around Pokemon chat lobby, an embedded MIDI that turns out to be a fan transcription of Yasunori Mitsuda's "Serge's Village Theme" from Chrono Cross with its sequencer's credits intact, seven complete third-party DLLs from 1996-2000, twenty images that never reach the screen, four pieces of scenery dragged off the edge of the map and forgotten, and a Send button wired to nothing. The document includes the full extraction toolchain in Python and a reproduction recipe.