Skip to content

FRML / LMRF — sprite animation

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 + 1 records and the authoring tool never writes the last one. The final frame therefore has no usable end offset; it must be computed from bytesPerRow × 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.

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.

+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 residue

Header 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].startOffset
frameEnd(k) = record[k+1].startOffset for k < F-1
frameEnd(F-1) = frameStart(F-1) + bytesPerRow × height × depth

The 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.

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.

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.

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

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.