FRML / LMRF — sprite animation
FRML / LMRF — sprite animation frames
Section titled “FRML / LMRF — sprite animation frames”Animated sprites — walk cycles, attacks, deaths, torches, water — are FRML
resources (Amiga, big-endian) or LMRF (DOS and Apple IIGS, little-endian).
Each is a small header, a frame table, and per-frame bitmaps in the same
Amiga interleaved planar encoding IMAG uses. They live
inside a resource fork.
The frame table has
F + 1records and the authoring tool never writes the last one. The final frame therefore has no usable end offset; it must be computed frombytesPerRow × height × depth, not read, and not clamped to the end of the payload. Every corpus this format appears in has been bitten by some version of this.
Container
Section titled “Container”The same wrapper as IMAG — a u32 decompressedSize then a compressed
stream. Small resources may be stored uncompressed, with the FRML header
directly at byte 0.
Payload — WIME Amiga
Section titled “Payload — WIME Amiga”+0x00 1 type 0 = 32-colour / 5-plane, 1 = 16-colour / 4-plane+0x01 1 frameCount F+0x02 (F + 1) × 6 frameTable bitmapStart ... interleaved planar bitmap data (after last) 6F − 2 uninitialised writer residueHeader invariant: bitmapStart == 2 + 6 × (F + 1) == 8 + F × 6.
Each 6-byte frame-table record, at 2 + 6k:
| Offset | Size | Field | Description |
|---|---|---|---|
+0x00 |
2 | startOffset |
u16 — absolute offset where frame k’s bitmap begins |
+0x02 |
1 | xPos |
X position when compositing onto the background |
+0x03 |
1 | yPos |
Y position |
+0x04 |
1 | width |
Frame width in pixels |
+0x05 |
1 | height |
Frame height in pixels |
Record 0’s startOffset is the bitmapStart field at 0x02 — the
header and the table overlap by design.
This is the game’s own addressing, not an inference: every consumer computes
A0 = frmlBase + 6 × frameIndex + 2, then reads (A0) as the bitmap start
and 2(A0)/3(A0)/4(A0)/5(A0) as x/y/w/h. Confirmed at LAB_0345 and
four other sites in the WIME disassembly.
Frame extents:
frameStart(k) = record[k].startOffsetframeEnd(k) = record[k+1].startOffset for k < F-1frameEnd(F-1) = frameStart(F-1) + bytesPerRow × height × depthThe last frame’s end offset is residue, not overflow
Section titled “The last frame’s end offset is residue, not overflow”An earlier reading of this blamed a u16 overflow — that the real end
offset exceeds 65,535 and the stored value wraps. That was wrong, and the
counter-evidence is unambiguous: the real last-frame end of all 19 WIME
Amiga FRMLs is below 65,536 (largest: AAnims.res #7 at 36,518), so every
one could have been stored exactly — and 0 of 19 stored values equal
the true end, or the true end mod 65,536. BScene.res #18’s last frame ends
at 448 and stores 0; #19 ends at 1,532 and stores 48,387. Wrapping cannot
produce either.
The stored bytes are simply record F, which the authoring tool never
writes. Observed garbage includes 0, 0x0001, 0x0003, 0x0060,
0x0800, 0x103C, 0x4000, 0xFFF0 and 0xFFFF — the small values give a
non-positive data size, which is why those frames were silently dropped
before the fix. The recovery code was already right; only the stated reason
was wrong.
Do not extend the last frame to the end of the payload. Exactly 6F − 2
bytes of uninitialised authoring-tool buffer follow it — real, non-zero and
meaningless. Zero deviation across 19/19 WIME Amiga FRMLs and 20/20 WIME
Apple IIGS LMRFs. Six of those 39 residue blocks have been matched
byte-exactly, at full length, to unrelated shipped files — Aegis Sonix’s 68k
executable, the DOS VGA build’s AANIMS.RES, and the DOS executables’
message-string table. That is what traced the residue to a shared master-data
tool rather than to either port.
The recovered last frames are real content: “look at camera” and “talking” poses, 35–71% non-transparent pixels. The original game is unaffected by any of this because it renders specific frames chosen by a lookup table rather than iterating all of them.
Bitmap encoding
Section titled “Bitmap encoding”Standard Amiga interleaved bitplanes, identical to IMAG —
bytesPerRow = ceil(width / 16) × 2, all planes for a scanline stored
consecutively, and depth = frameDataSize / (bytesPerRow × height). In valid
data the computed depth matches the type-derived one (type 0 → 5 planes,
type 1 → 4).
Colour index 0 is transparent. Because widths are word-aligned, rows can
carry pad bits on the right edge when width isn’t a multiple of 16.
For 16-colour sprites composited into a 5-bitplane scene, the active colours are the upper half of the 32-colour scene palette — entries 16–31, not 0–15.
Variants
Section titled “Variants”Apple IIGS (LMRF) — 4-byte header. No type field; frameCount moves
to byte 0, and bitmapStart is a u16 LE at +0x02. Same invariant, same
6-byte records with LE startOffset, and always depth 4.
Excal family (Spirit, Vengeance, Conan, Legend) — type 0x05. A 4-byte
header with type = 0x05 at +0x00, frameCount at +0x01 and a u16 BE
bitmapStart at +0x02, then the same F + 1 record table. Depth is
inferred from the first frame’s data size rather than the type field.
An earlier revision of the spec claimed the Excal family has “four bytes of
padding between the frame table and the bitmap data, where WIME has none.”
There is no padding and no difference — WIME’s table is F + 1 records too.
Three decode bugs a 162-resource corpus exposed
Section titled “Three decode bugs a 162-resource corpus exposed”Warriors of Legend’s LMRF corpus uses the same type-0x05 header as the
rest of the Excal family, but 23 of 162 resources failed to decode. All three
root causes were in the decoder, not the header shape. The corpus is now
162/162.
1. The PackBits-vs-LZSS tie-break trusted a header-only check. The decompressor tries both schemes and picks whichever hits the declared length and passes a validity check that only examines the 4-byte header plus frame 0’s table entry. On 18 real resources both candidates reached the exact declared length and passed — but PackBits was decompressing an actually-LZSS stream, producing byte-identical output through frame 0’s header bytes and a single repeated filler byte for every frame after it. Fixed with a tie-breaker that requires frame 0’s declared end offset to divide evenly into a plane count of 1–8, applied only when both candidates already pass the shallow check.
2. Single-frame animations store their only frame’s end offset as 0.
Unlike a last-frame-of-many, a frameCount === 1 resource has no earlier
frame to take a known depth from, so recovery had nothing to work with and
dropped the frame. Confirmed on 4 resources: each leaves exactly a 2-byte
trailing pad after bytesPerRow × height × 5, and no other depth in 1–8 fits
the remaining buffer within an 8-byte tolerance. Fixed by searching depths
1–8 against the remaining length in that specific condition.
3. frameCount === 0 means “not an animation”. extra.res LMRF #2000
decodes as zero frames, which every prior path read as “no content”. Byte 0
is in fact a plain IMAG 6-byte-header marker, and bytes 0–5 read as
width=256, height=256, depth=5 — exactly 6 + ceil(256/16) × 2 × 256 × 5 = 40,966 bytes, matching the declared decompressed size. The pixels render as
a coherent 256×256 tile atlas, the same role as Spirit and Vengeance’s
IMAG #500.
Resource inventory
Section titled “Resource inventory”| Game | File(s) | Count | Status |
|---|---|---|---|
| WIME Amiga | AAnims.res (5), BScene.res (14) |
19 | All decode — battle sprites and scene animation |
| WIME Apple IIGS | ANIMS.RES |
20 | All decode |
| Spirit of Excalibur | PLAYA32A.RES (34), OVERA32A.RES (6) |
40 | 38/40 — ids 900 and 914 have a valid header but corrupt table entries 1–4 |
| Vengeance | ANI32A.RES (3), ANI32.RES (52) |
55 | 3 decode; the 52 in ANI32.RES use a non-standard bitmapStart and are undecoded |
| Conan | ANIM32.RES, ANIS32.RES, INTFM32.RES |
101 | 87/101 decode |
| Warriors of Legend | various | 162 | 162/162 — see the three bugs above |
Where the full evidence lives
Section titled “Where the full evidence lives”docs/formats/frml.md in the main repository (not part of this site).
WIME’s per-character recolouring at blit time is game-specific and lives in
docs/wime/amiga/frml-colour-variants.md; undecoded header variants are in
docs/reference/eliminated/imag-frml-open-variants.md.