Post

Reverse Engineering Harvester with Ghidra and Codex - Part 3: File Formats

Reverse Engineering Harvester with Ghidra and Codex - Part 3: File Formats
Series: Reverse Engineering Harvester

This review is part of the Reverse Engineering Harvester series, where I document my journey of reverse engineering the 1996 DOS game Harvester to re-implement its game engine in ScummVM.

Article 3 of 3 in this series.

File Formats

This document tracks file-format facts that are confirmed by the current Harvester reverse-engineering work. It is intentionally conservative: if a field or behavior is not supported by code or verified runtime notes, it is left out.

The sections below combine three sources of evidence: the current ScummVM Harvester engine code, named native functions and data types in Ghidra, and spot-checks against extracted sample files from the Harvester CD image. Unknown or only partially understood fields stay marked as such.

DAT (XFILE archive payload)

Harvester’s .DAT files act as payload containers for the game’s numbered XFILE resource sets. The important wrinkle is that the .DAT file is not the whole format by itself: the loader depends on a sidecar INDEX.00N file for the directory. In other words, the .DAT holds the bytes, while the INDEX.00N records tell the engine where each member starts, how large it is, and whether it must be unpacked after reading.

At confirmed cold start, the original game mounts these pairs in this order:

SetIndex fileData fileNotes
1INDEX.001HARVEST.DATFirst archive-backed set mounted during startup. Confirmed startup art and UI assets are loaded through this set.
2INDEX.002SOUND.DATSecond numbered set mounted during startup.
3INDEX.003HARVEST2.DATThird numbered set mounted during startup.

Summary

PropertyValue
Format roleArchive payload file used by the XFILE resource layer
Companion metadataRequired sidecar INDEX.00N directory file
Archive-wide header in .DATNo confirmed archive-wide header or in-file directory has been required by the current loader
Addressing modelMembers are looked up by path in INDEX.00N, then read from absolute offsets in the .DAT
Path syntax in game codeArchive-backed lookups use <set-number>:\path\to\file.ext
EndiannessMixed in the sidecar index: signature is checked as big-endian XFLE; numeric fields are little-endian 32-bit values
CompressionOptional per-entry packing; unpacked entries are read directly, packed entries are expanded after read

.DAT payload layout

OffsetSizeDescription
0x00variableRaw member data addressed by the sidecar index. The current loader treats the file as a byte reservoir and does not require a confirmed global header before opening entries.

This is sparse compared with many archive formats. The format’s structure is directory-driven rather than self-describing. All meaningful per-file metadata currently comes from the matching INDEX.00N file.

INDEX.00N directory record layout

Each confirmed sidecar index is read as a flat array of 0x94-byte records:

OffsetSizeTypeNameDescription
0x000x04ASCIIsignatureXFLE magic. The loader rejects records whose first four bytes do not match this tag.
0x040x80char[128]pathNUL-terminated resource path string. This is the logical member name used for later lookups.
0x840x04uint32learchive_offsetAbsolute offset of the member data inside the companion .DAT file.
0x880x04uint32lestored_sizeNumber of bytes stored in the .DAT for this member.
0x8c0x04uint32lepacked_flag0 means the member is stored verbatim. Any nonzero value takes the packed-entry decode path. The current loader does not distinguish between different nonzero flag values.
0x900x04uint32leunpacked_sizeExpected output size after decode for packed entries.

Packed entry stream

When packed_flag != 0, the loader reads stored_size bytes from the .DAT and expands them into an unpacked_size output buffer using a simple control-byte stream:

Control byte rangeMeaning
0x00 to 0x80Copy the next control bytes literally into the output stream.
0x81 to 0xffRead one byte and repeat it control - 0x80 times.

The decode loop stops once either the compressed input is exhausted or the output buffer reaches unpacked_size. Entries that fail to produce exactly unpacked_size bytes are treated as invalid.

Path handling notes

BehaviorDetail
SeparatorsResource paths are normalized from DOS-style backslashes to forward slashes during lookup.
Prefix strippingLeading ./, leading /, and <digit>:/ prefixes are stripped during normalization.
Case handlingArchive member lookups are case-insensitive in the current implementation.
Loose files vs archive pathsBare relative paths are handled separately by the direct-file path builder; <digit>:\... paths select an archive set instead.

Practical interpretation

For extraction or tooling work, the current evidence supports treating Harvester’s .DAT archives as a two-file format:

  1. Read the matching INDEX.00N file as a sequence of 0x94-byte XFLE records.
  2. Use each record’s archive_offset and stored_size to slice bytes from the companion .DAT.
  3. If packed_flag is zero, the slice is the final file.
  4. If packed_flag is nonzero, expand the slice with the literal/repeat decoder above until unpacked_size bytes are produced.

That model is enough to explain the startup resource mounts already confirmed in the reverse-engineering notes, and it matches the current ScummVM-side archive loader.

RCS (Quick-tip text lists)

The only confirmed .RCS use so far is ADJHEAD.RCS, the quick-tips file shown by the startup/options overlay. Both native run_quick_tips_screen and the ScummVM Flow::loadQuickTips path treat it as plain text rather than as a binary container.

Summary

PropertyValue
Format rolePlaintext list of quick-tip strings
Confirmed consumerStartup quick-tips overlay and options-menu quick-tips screen
EncodingPlain ASCII text in the sampled file
HeaderNone
DelimitersCR/LF line endings in the sampled file; loaders split on \n and ignore \r

File layout

Offset / unitSizeDescription
0x00..EOFvariableSequence of text lines. Each non-empty trimmed line becomes one quick-tip string.

Notes

  • The sampled ADJHEAD.RCS begins directly with readable text: Double click the left mouse button....
  • The native quick-tips path advances through the file with read_line_from_file_stream, wraps back to the start on EOF, and chooses a random starting point the first time it runs.
  • This is a good example of Harvester’s resource pipeline staying pragmatic: not every gameplay-facing asset is wrapped in a custom binary format.

DIALOG.RSP (Dialogue response and keyword text)

DIALOG.RSP is a plaintext line table used by the dialogue UI. It does not appear to contain ids, offsets, or a binary header; the engine indexes it by zero-based line number and then uses the line text directly.

Summary

PropertyValue
Format rolePlaintext dialogue response / keyword string table
Confirmed consumersload_dialogue_response_line, response menu, keyword menu
EncodingPlain ASCII text in the sampled file
HeaderNone
Addressing modelZero-based line index

File layout

UnitDescription
Line nOne response, keyword label, or menu text string
/ within a lineIn response/keyword UI contexts, splits one source line into multiple visible menu items

Notes

  • The native loader reads a requested zero-based line, strips the trailing CR/LF, and returns the shared text buffer directly.
  • run_dialogue_response_menu and run_dialogue_keyword_menu split visible options on /; there is no confirmed hidden topic-id layer behind those labels.
  • load_dialogue_index also pulls zero-based line 13 from DIALOG.RSP to seed the default keyword topic; in the sampled file that line is BYE (one-based line 14).
  • DIALOG.RSP shows Harvester leaning on designer-editable text tables even inside a fairly custom dialogue stack.

DIALOGUE.IDX (Subtitle index)

DIALOGUE.IDX is an XOR-obfuscated text file that maps voice sample ids to subtitle strings. The native load_dialogue_index path and the ScummVM Text::loadDialogueIndex reimplementation both decode it with XOR 0xAA while leaving CR/LF intact.

Summary

PropertyValue
Format roleVoice-id to subtitle-text index
Encoding on diskASCII text obfuscated with XOR 0xAA on every byte except CR/LF
HeaderNone
Confirmed delimitersNUL, LF, CR, and form-feed (0x0c)
Key native typeDialogueIndexEntry { wav_id, text_offset, text_length }

Decoded stream layout

Sequence elementDescription
ASCII decimal tokenwav_id for one spoken line
Delimiter runOne or more of NUL, LF, CR, FF
ASCII text tokenSubtitle text for that voice id
Delimiter runEnds the subtitle record and starts the next id

Derived index entry

FieldMeaning
wav_idPositive decimal voice/sample id
text_offsetByte offset of the decoded subtitle text inside the decoded blob
text_lengthSubtitle length, clamped to 0x19a bytes by the native loader

Notes

  • After XOR decode, the sampled file starts with a simple alternating pattern: 1, "junk init", 7, "Yes?", 11, "I need some help...", and so on.
  • The native loader builds a 3000-entry in-memory table and keeps the decoded text blob around so play_dialogue_line can seek back into it cheaply.
  • Architecturally, this splits dialogue cleanly in two: DIALOGUE.IDX carries spoken-line subtitles keyed by numeric wav ids, while DIALOG.RSP carries menu-facing response text.
1
2
3
4
5
6
7
8
9
10
11
python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' DIALOGUE.IDX | head -n 10
1
"junk init"
7
"Yes?"
11
"I need some help... Mister...?"
15
"Postmaster Boyle.  What can I do you for today?"
25
"Sorry, youngster, we're out of applications right now."

SCR (Town/world startup script)

HARVEST.SCR is the central world-definition and startup-script file. It is XOR-obfuscated text, not bytecode. The native load_xor_obfuscated_town_script path and the ScummVM Script::decode path both XOR each non-CR/LF byte with 0xAA, then parse whitespace-separated records with quoted strings preserved.

Summary

PropertyValue
Format roleWorld database, startup configuration script, and command graph
Encoding on diskASCII text obfuscated with XOR 0xAA on every byte except CR/LF
HeaderNone
Comment syntaxLines whose first non-space character is { are skipped
TokenizationWhitespace-delimited tokens with quoted strings kept intact

Confirmed record forms

TagLeading numeric fieldsFields after the tag
ENTRANCEx y zdirection roomName entranceName
MAP_ENTRANCEmapX mapY initialPanelIndexentryName
MAP_LOCATIONminX minY maxX maxY panelIndex labelX labelYlabelText destinationEntranceName
ROOMminZ maxZ maxZScreenY minZScreenY fullScaleZ maxZScalePercentroomName musicPath reservedString38 reservedString3c reservedString40 palettePath dimmable onEnterCommand onExitCommand
OBJECTinitialX initialY boundsX2 boundsY2 initialZ zExtentinitialOwnerOrRoom objectName spritePath altSpritePath reservedString40 inventoryTextKey reservedXFlag identTextKey operatable visible actionTag interactionLabel
ANIMx y z frameDelayroomName resourcePath animName active visible looping backward pingPong remove
NPCx y z frameDelayroomName modelPath npcName monsterfyTargetName active visible onDeathActionTag audioPath entityInitArg
MONSTERx y z ...Sample data and native analysis confirm sound-trigger timing columns for attack, hit, footstep, and death. The intermediate string columns at offsets 0x38, 0x3c, 0x44, and 0x48 remain reserved in current data and have no recovered read-side consumers.
REGIONleft top right bottom minZ maxZregionName direction roomName actionTag startEnabled cursorEnabled
FLAGnonename value
COMMANDnonetriggerTag opcodeName arg1 arg2 arg3 [arg4]
TEXTnonekey boxName value
HEADnoneheadId portraitPath
USEITEMnoneitemName ownerOrRoom targetName actionTag

Notes

  • The sampled file starts with readable structural records immediately after XOR decode, for example ENTRANCE, OBJECT, and later ROOM, TEXT, HEAD, and COMMAND.
  • Paths embedded in script records are not uniform: archive-backed resources use 1:\..., 2:\..., 3:\..., while some direct-file assets are bare relative paths such as dialogue.idx.
  • GOFLIC and GODEATHFLIC are especially revealing command names. In the sampled script, those opcodes point to .FST paths, which suggests the script vocabulary preserved older naming while the shipping runtime movie path used FST files.
  • HARVEST.SCR shows that Harvester’s resource architecture is data-driven at the top level: rooms, objects, dialogue portraits, commands, music, palettes, and cutscene triggers all converge here.
1
2
3
4
5
6
7
8
9
10
11
python3 -c 'import sys,pathlib,signal; signal.signal(signal.SIGPIPE, signal.SIG_DFL); d=pathlib.Path(sys.argv[1]).read_bytes(); sys.stdout.buffer.write(bytes(b if b in (10,13) else b ^ 0xAA for b in d))' HARVEST.SCR | head -n 10
{// HARVESTER (c) 1995-96 Scripting Language
{// Town
        0   0   0   ENTRANCE "BACK" "TOWN_2_LODGE" "SERGEANT_2_LODGE"
        0   0   0   ENTRANCE "FRONT" "" "SAVE_GAME"
        492 67  554 96   0  1 OBJECT "INVENTORY" "INV_EXIT" "" "" "" "" "" "X" "F" "T" "" "Inventory"
        299 0   0   0    2  1 OBJECT "NULL_ID"          "EXIT_BM"  "1:\GRAPHIC\OTHER\EXITSIGN.BM" "" "" "" ""  "" "F" "T" "" "exit"
        299 0   375 61   0  1 OBJECT "NULL_ID"          "EXIT_HS"  ""                             "" "" "" "X" "" "T" "T" "" "exit"

{// inventory health indicator
     72 314 0   0  -12  1 OBJECT "INVENTORY" "INV_STAT1" "1:\graphic\other\head-a1.bm" "" "" "INV_STAT_ST" "" "" "F" "F" "" "Health_Indicator"

BM (Static indexed bitmap)

.BM is Harvester’s simplest custom image format: a small fixed header followed by raw 8-bit indexed pixels. It is used for UI panels, portraits, inventory art, help screens, and other non-animated images.

Summary

PropertyValue
Format roleRaw 8-bit indexed bitmap
Confirmed consumersspawn_bitmap_entity_from_resource, reload_bitmap_entity_pixels_from_resource, menu/help/dialogue overlay loaders
CompressionNone confirmed
Pixel format1 byte per pixel, palette-indexed

File layout

OffsetSizeTypeDescription
0x000x04uint32lewidth
0x040x04uint32leheight
0x080x04uint32leUnused/reserved in current analysis. Both native and ScummVM loaders ignore it; sampled MOUSHELP.BM stores zero here.
0x0cwidth * heightbytesRaw indexed pixel payload, row-major

Notes

  • The native binary reuses the same RawBitmap/BitmapBuffer shape in memory: { width, height, pixels }.
  • The format itself does not encode transparency, but many callers render it with palette index 0 treated as transparent.
  • Compared with many later adventure engines, this is an aggressively direct format: no chunking, no palette sidecar inside the file, and no per-row metadata.

PAL (Standalone palette)

Harvester’s .PAL files are raw palette payloads with no header. The native palette upload helpers and ScummVM Art::loadPalette both treat them as 256 RGB triplets.

Summary

PropertyValue
Format roleStandalone 256-color palette resource
Confirmed consumersRoom setup, menus, help screens, wait overlay, town map
HeaderNone
Payload size768 bytes (256 * 3)

File layout

Offset / rangeSizeDescription
0x000..0x2ff768 bytes256 consecutive (R, G, B) triplets stored as 8-bit channel values

Notes

  • Native upload_palette_to_vga forces palette index 0 to black at upload time, applies brightness scaling, and then shifts the stored 8-bit channels down to VGA’s 0..63 DAC range.
  • The sampled INVHELP.PAL contains full-range byte values up to 0xff, which matches the native analysis that .PAL is stored as 8-bit RGB, not pre-divided 6-bit VGA values.
  • This makes .PAL a nice contrast with FST’s embedded movie palettes, which are stored in 6-bit VGA form inside each frame payload.

TRY IT OUT!

1
2
python3 -m pip install pillow
python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); w,h=struct.unpack_from("<II",d,0); i=Image.frombytes("P",(w,h),d[12:12+w*h]); p=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n)); i.putpalette(p); i.show()' /path/to/*.BM [/path/to/*.PAL]

python3 -c '...' GRAPHIC/OTHER/INVHELP.BM

python3 -c '...' GRAPHIC/OTHER/INVHELP.BM GRAPHIC/PAL/INVHELP.PAL

CFT (Bitmap font)

.CFT packages a bitmap font as a fixed metrics header plus one raw 8-bit atlas image. The native load_font_resource and the ScummVM Text::loadFont / HarvesterCftFont code agree on the basic structure.

Summary

PropertyValue
Format roleBitmap font resource with metrics tables and atlas
Glyph count256 slots
Rendering modelEach glyph is a horizontal slice out of one shared atlas bitmap
Confirmed consumersMenu text, room labels, dialogue, save/load UI, text-entry widgets

File layout

OffsetSizeTypeDescription
0x0000x40char[64]NUL-terminated font name (HARVFONT in the sampled file)
0x0400x02uint16leFont height
0x0420x200uint16le[256]Glyph start-X table
0x2420x200uint16le[256]Glyph width table
0x4420x02uint16leSpace width
0x4440x04unknownUnused/reserved in current analysis
0x4480x04uint32leAtlas width
0x44c0x04uint32leAtlas height
0x4500x04unknownUnused/reserved in current analysis; sampled HARVFONT.CFT stores zero here
0x454atlasWidth * atlasHeightbytesRaw atlas pixels, row-major, 8-bit indexed

Notes

  • The renderer derives each glyph by slicing width pixels from x inside the shared atlas. There is no per-glyph bitmap chunking.
  • drawHeight is effectively atlasHeight - 1 in the current font renderer, which matches the native behavior of treating the last row as non-drawing padding.
  • The font renderer treats both ' ' and '_' as space-width characters. That ties neatly back to the script/text resources, where underscore-heavy identifiers and UI labels coexist with visible text.

TRY IT OUT!

1
python3 -c 'from PIL import Image; import sys,struct; d=open(sys.argv[1],"rb").read(); aw,ah=struct.unpack_from("<II",d,0x448); h=max(1,min(struct.unpack_from("<H",d,0x40)[0] or ah-1,ah-1)); sw=struct.unpack_from("<H",d,0x442)[0] or 1; s=[struct.unpack_from("<H",d,0x42+i*2)[0] for i in range(256)]; w=[struct.unpack_from("<H",d,0x242+i*2)[0] for i in range(256)]; a=Image.frombytes("L",(aw,ah),d[0x454:0x454+aw*ah]); t=sys.argv[2]; W=sum(sw if ord(c)>255 or c in " _" or w[ord(c)]<=0 else w[ord(c)] for c in t); i=Image.new("L",(max(1,W),h),0); x=0; exec("for c in t:\n o=ord(c)\n gw=sw if o>255 or c in \" _\" or w[o]<=0 or s[o]>=aw else w[o]\n if gw and o<=255 and c not in \" _\" and w[o]>0 and s[o]<aw: i.paste(255,(x,0,x+gw,h),a.crop((s[o],0,s[o]+gw,h)))\n x+=gw"); i.resize((max(1,i.width*4),max(1,i.height*4)),Image.NEAREST).show()' "GRAPHIC/FONT/HARVFNT2.CFT" "Hello World"

ABM (Animated bitmap / sprite strip)

.ABM is Harvester’s main custom sprite/animation format. It backs cursor art, actor sprites, room animations, combat entities, and wait-overlay animation frames.

Summary

PropertyValue
Format roleMulti-frame indexed animation resource
Confirmed consumersattach_abm_resource_to_entity, spawn_abm_entity_from_resource, startup art loaders, room animation runtime
Pixel format8-bit indexed pixels
CompressionOptional per-frame RLE-like stream

File header

OffsetSizeTypeDescription
0x000x04uint32leframe_count
0x040x04uint32leNative runtime uses this value to size the temporary decoded-frame buffer before adding 0x10 bytes of slack. The current ScummVM loader does not otherwise interpret it. Sampled BLOOD.ABM stores 0x888 here.
0x08variablesequenceFirst frame record begins here

Per-frame record

Offset within frameSizeTypeDescription
0x000x04int32lex_offset
0x040x04int32ley_offset
0x080x04uint32lewidth
0x0c0x04uint32leheight
0x100x01bytecompressed_flag
0x110x04uint32leencoded_size
0x15encoded_sizebytesEncoded or raw pixel payload for this frame

Compressed frame stream

Control byte formMeaning
0x00..0x7fCopy the next control bytes literally
0x80..0xffRead one byte and repeat it control & 0x7f times

Notes

  • Each frame decodes to exactly width * height bytes of indexed pixels.
  • The sampled BLOOD.ABM starts with 5 frames; its first frame is offset (4, 0), size 38 x 50, and marked compressed.

Try it out!

1
python3 -c $'from PIL import Image\nimport sys,struct,tempfile,pathlib,webbrowser\n\ndef dec(s,n):\n i=0; o=bytearray()\n while i<len(s) and len(o)<n:\n  k=s[i]; i+=1\n  if k<128:\n   c=min(k,len(s)-i,n-len(o)); o+=s[i:i+c]; i+=c\n  elif i<len(s):\n   o.extend([s[i]]*min(k&127,n-len(o))); i+=1\n return bytes(o)\n\nd=open(sys.argv[1],"rb").read()\npal=open(sys.argv[2],"rb").read()[:768] if len(sys.argv)>2 else bytes(c for n in range(256) for c in (n,n,n))\nfc=struct.unpack_from("<I",d,0)[0]; off=8; F=[]; minx=miny=10**9; maxx=maxy=-10**9\nfor _ in range(fc):\n x,y,w,h=struct.unpack_from("<iiii",d,off); c=d[off+16]; n=struct.unpack_from("<I",d,off+17)[0]; s=d[off+25:off+25+n]; off+=25+n; p=s[:w*h] if not c else dec(s,w*h); F.append((x,y,w,h,p)); minx=min(minx,x); miny=min(miny,y); maxx=max(maxx,x+w); maxy=max(maxy,y+h)\nminx=min(0,minx); miny=min(0,miny); W=maxx-minx; H=maxy-miny; G=[]\nfor x,y,w,h,p in F:\n src=Image.frombytes("P",(w,h),p); src.putpalette(pal); m=src.point(lambda v:0 if v==0 else 255,"L"); fr=Image.new("RGBA",(W,H),(0,0,0,0)); fr.paste(src.convert("RGBA"),(x-minx,y-miny),m); G.append(fr)\nf=tempfile.NamedTemporaryFile(suffix=".gif",delete=False).name; G[0].save(f,save_all=True,append_images=G[1:],duration=100,loop=0,disposal=2,transparency=0); webbrowser.open(pathlib.Path(f).as_uri())' /path/to/.ABM [/path/to/.PAL]

For example:
python3 -c $'...' "GRAPHIC/ROOMANIM/CLOAK.ABM"

python3 -c $'...' "GRAPHIC/ROOMANIM/CLOAK.ABM" "GRAPHIC/PAL/INVHELP.PAL"

CMP (FCMP-compressed audio)

.CMP is Harvester’s custom compressed audio wrapper. The sampled files begin with FCMP, and the native load_sound_sample / load_dialogue_voice_sample code plus the ScummVM decodeHarvesterFcmp path all treat that payload as IMA-ADPCM-like compressed audio.

Summary

PropertyValue
Format roleCompressed audio for music, dialogue voice, and sound effects
Confirmed magicFCMP
Confirmed codecIMA-ADPCM-style nibble stream using the standard step/index tables recovered in the binary
Supported output depths8-bit and 16-bit PCM

File layout

OffsetSizeTypeDescription
0x000x04ASCIIFCMP magic
0x040x04uint32leCompressed payload size
0x080x04uint32leSample rate in Hz
0x0c0x02uint16leOutput bits per sample (8 or 16)
0x0evariablebytesADPCM payload

Notes

  • Sampled MENACE.CMP starts with FCMP, a payload size of 0x0db48b, sample rate 22050, and 16 bits per sample.
  • The loader family is tolerant: some call paths accept either FCMP or raw WAVE data. The .CMP extension itself points to the compressed path, but the runtime checks the actual file signature before decoding.
  • From an architectural perspective, this is the audio-side equivalent of Harvester’s XFILE abstraction: one wrapper format reused across music, speech, and effects.

FST (Cutscene / streamed animation)

.FST is Harvester’s custom streamed movie format. It combines a file header, a compact per-frame index table, block-coded video payloads, optional per-frame palettes, and per-frame audio chunks.

Summary

PropertyValue
Format roleCutscene / transition movie format
Confirmed magicFST2 (0x32545346 on disk, little-endian)
Video model8-bit indexed frames decoded in 4 x 4 blocks
Audio modelPer-frame audio chunks described by the frame index table
Confirmed consumersrun_fst_sequence_player, play_fst_sequence, startup intro path, scripted room transitions

File header

OffsetSizeTypeDescription
0x000x04ASCIIFST2 magic
0x040x04uint32leFrame width
0x080x04uint32leFrame height
0x0c0x04uint32lemax_frame_size
0x100x04uint32leframe_count
0x140x04uint32leframe_rate
0x180x04uint32lesample_rate
0x1c0x04uint32lebits_per_sample
0x20frame_count * 6arrayFstFrameIndexEntry[frame_count]

Frame index entry

Offset within entrySizeTypeDescription
0x000x04uint32levideo_size
0x040x02uint16leaudio_size

Video payload for one frame

Sequence elementDescription
uint16 bit_countNumber of bits in the control bitstream
((bit_count >> 3) + 1) bytesPacked control-bit stream
Optional 256 * 3 bytesPresent only when the first control bit is set; this is a VGA-style 6-bit palette block, not a .PAL resource block
Block payload streamOne record per changed 4 x 4 tile across the frame

Block coding

Control bits / payloadMeaning
Changed-bit 0Leave the existing 4 x 4 block unchanged
Changed-bit 1, mode-bit 0, 16-byte payloadLiteral 4 x 4 pixel block
Changed-bit 1, mode-bit 1, 4-byte payloadTwo colors plus a 16-bit mask describing a 4 x 4 block

Notes

  • Sampled VIRGLOGO.FST is 320 x 200, stores 131 frames, plays at 15 fps, and carries 22050 Hz / 16-bit audio.
  • The native and ScummVM players both treat FST as a streaming format: read one frame, queue its audio, decode its video, and advance without loading the whole movie at once.
  • HARVEST.SCR uses GOFLIC/GODEATHFLIC opcodes to trigger .FST files. That mismatch between opcode names and file extension is a strong architectural clue that FST replaced or wrapped an older movie concept without rewriting the script vocabulary.
  • The censorship path also shows how self-contained the format is: FST can carry its own palette updates frame-by-frame, while the player temporarily swaps in an external CENSORED.PCX overlay when gore is disabled.

PCX (Standard indexed still image)

Harvester does use standard .PCX files in at least one confirmed place: the censorship overlay shown during certain FST sequences when gore is disabled.

Summary

PropertyValue
Format roleStandard 8-bit indexed PCX still image
Confirmed consumerNative load_pcx_bitmap; ScummVM FST censorship overlay loader
EncodingPCX RLE
PaletteTrailing 256-color palette block when present

Confirmed header fields

OffsetSizeTypeDescription
0x000x01byteManufacturer (0x0a in sampled CENSORED.PCX)
0x010x01byteVersion (0x05 in the sample)
0x020x01byteEncoding (0x01 = RLE)
0x030x01byteBits per pixel per plane (0x08 in the sample)
0x040x02uint16lexMin
0x060x02uint16leyMin
0x080x02uint16lexMax
0x0a0x02uint16leyMax
0x410x01byteColor planes (0x01 in the sample)
0x420x02uint16leBytes per line (320 in the sample)
0x80variablebytesRLE-compressed image data
EOF - 7690x01byteStandard palette marker 0x0c in the sampled file
EOF - 7680x300bytes256-color palette block when present

Notes

  • Sampled CENSORED.PCX is a textbook single-plane 320 x 200 PCX, which is why the ScummVM port can use the generic PCX decoder for it.
  • The native loader contains two Harvester-specific wrinkles: it trims the optional 0x0c palette marker by jumping straight to the last 0x300 bytes, and when the logical width from xMax is one pixel smaller than bytesPerLine, it trims the padded stride byte after decode.

FLC (Standard Autodesk FLIC animation)

Harvester does ship .FLC files, and the sampled files match the standard Autodesk FLIC/FLC header rather than a Harvester-specific wrapper. The current Harvester engine work has not yet recovered the exact native Harvester-side call path that consumes them, so this section stays limited to what is directly supported by sample bytes, binary strings, and the generic ScummVM FLIC decoder.

Summary

PropertyValue
Format roleStandard 8-bit FLIC/FLC animation files bundled with Harvester
EvidenceSample files such as GRAPHIC/ROOMANIM/HARVPNTR.FLC and GRAPHIC/FST/CHESMOV1.FLC; native strings Could not load flic. and flic.cpp; hardcoded .flc paths in the binary
Harvester-specific loader statusNot yet fully recovered in current Harvester analysis
Decoder in repoGeneric ScummVM video/flic_decoder.cpp

Confirmed standard header fields

OffsetSizeTypeDescription
0x000x04uint32leFile size
0x040x02uint16leMagic 0xaf12
0x060x02uint16leFrame count
0x080x02uint16leWidth
0x0a0x02uint16leHeight
0x0c0x02uint16leColor depth (8 in the sampled file)
0x0e0x02uint16leFlags
0x100x04uint32leFrame delay in milliseconds
0x500x04uint32leOffset of frame 1
0x540x04uint32leOffset of frame 2
0x80variablebytesFirst frame/chunk stream begins here in the generic decoder

Notes

  • Sampled HARVPNTR.FLC starts with a valid FLC header: size 0x214a, magic 0xaf12, 10 frames, 26 x 26, and 8-bit color.
  • The decoded HARVEST.SCR sample does not reference .FLC files directly; its GOFLIC opcodes currently point to .FST files instead. That suggests .FLC belongs to a parallel or older asset path rather than the main shipping story-transition pipeline. This last point is an inference from the sampled script and should stay provisional.
  • Taken together, the resource set shows three animation strata: lightweight sprite ABMs, streamed FST movies, and a residual standard FLC layer. That is a strong narrative hook for explaining how mixed-tool resource pipelines often survive into shipped games.

TRY IT OUT

1
2
ffplay -loop -1 "GRAPHIC/ROOMANIM/HARVPNTR.FLC"
ffmpeg -i "GRAPHIC/ROOMANIM/HARVPNTR.FLC" out.gif

This post is licensed under CC BY 4.0 by the author.