Skip to content

SH — Shape 3D Model (.SH)

.SH files hold the 3D geometry for every aircraft, vehicle, weapon, and scene object — 1275 of them in FA_2.LIB. Each is a Phar Lap PE/LE executable whose CODE section carries a shape bytecode stream: vertex buffers, faces, texture references, and conditional jumps the engine interprets at render time (some models embed raw x86 code as well).

Tools

fx

fx sh info   <file.SH>               # scale, bounding box, vertex/face count, textures
fx sh unpack <file.SH> [-o out.obj]  # export Wavefront OBJ (with usemtl directives)

The exported OBJ uses mtllib shape.mtl and usemtl <texture_name> directives when textures are present. A .mtl file is not written automatically — create one manually if needed for rendering. The library API behind these commands (sh_parse_info / sh_parse_mesh / sh_to_obj) is documented in api.md.

Other Tools

  • Blender — free, cross-platform; best option for inspecting and measuring exported OBJ geometry
  • MeshLab — free, cross-platform; lightweight viewer with basic mesh statistics
  • FASHion — free, FA-specific; vertex repositioning only (see workflow below)
  • SketchUp 8 — free (legacy version required by FASHion plugin); use alongside FASHion
  • 3ds Max $ — paid; full mesh editing if a pack command is added in future

Community editing workflow (FASHion + SketchUp 8):

  • FASHion can only reposition individual vertices; it cannot add or remove vertices, change face topology, or alter the overall mesh structure. The rebuild operation overwrites the original file in place — always back up before editing.
  • SketchUp 8 is used as the 3D viewport. FASHion exports a vertex coordinate file that SketchUp loads via a plugin; after adjustments the modified coordinates are exported back and FASHion rebuilds the shape.

  • Unpack the target .SH from its .LIB with fx lib unpack.

  • Open the .SH in FASHion; export the vertex file.
  • Load into SketchUp 8 (install outside Program Files to avoid permissions issues).
  • Reposition vertices as needed.
  • Export from SketchUp; rebuild in FASHion → overwrites the .SH.
  • Repack with fx lib patch.

For bulk vertex edits (e.g. scaling an entire section), the community workflow converts the vertex file to a spreadsheet, applies transformations numerically, then reconverts before importing back into FASHion.

SH files with x86-only geometry (65/1275 in FA — see Round-Trip Notes) cannot be edited with FASHion and require direct x86 disassembly for modification.

File Layout

All multi-byte integers are little-endian.

SH shape structure: the Phar Lap executable container chain leading to the CODE section, whose 14-byte header precedes an instruction stream of geometry records with three conditional selectors — detail, LOD, damage — branching to alternative geometry blocks

Container: Phar Lap PE/LE Executable

SH files are Phar Lap PE/LE executables. The shape bytecode lives in the CODE section. Parse via the standard MZ/PE header chain:

data[0x00..0x02]  MZ signature: 'M' 'Z'
data[0x3C..0x40]  e_lfanew (u32 LE) -> offset of PE/LE header
pe[0..2]          Phar Lap signature: 'P' 'L' (same layout as standard 'P' 'E')
pe[4..6]          Machine (u16, ignored)
pe[6..8]          NumberOfSections (u16)
pe[20..22]        SizeOfOptionalHeader (u16)
pe[24 + SizeOfOptionalHeader ..]   Section table (40 bytes per entry)

Section entry layout:
  [0..8]    Name (8 bytes, null-padded) -- always "CODE" for the code section
  [8..12]   VirtualSize (u32)
  [12..16]  VirtualAddress (u32)
  [16..20]  SizeOfRawData (u32)   <-- use this
  [20..24]  PointerToRawData (u32) <-- use this as file offset
  [24..40]  (ignored)

Take the first section with PointerToRawData > 0 — that is the code section. Always named CODE. PointerToRawData is the file offset; SizeOfRawData is its length.

Code Section Structure

[FF FF]              2-byte signature
[radius_world i16]   authored bounding radius, world units — engine-unused
[radius i16]         approximate bounding-sphere radius, shape units
[scale i16]          coordinate scale (bytes 6..8): see table below
[ext[0] i16]         bounding extent X (half-width)
[ext[1] i16]         bounding extent Y (half-depth)
[ext[2] i16]         bounding extent Z (half-height)
[instruction stream] variable-length opcodes

radius (bytes 4..6) is an authored, approximate bounding-sphere radius in shape units: across all 1275 FA_2.LIB shapes it is always positive (min 10) and correlates 0.996 with ‖ext‖₂, sitting anywhere between the true max vertex norm and the (loosely padded) header extents. The engine folds it into the projection-shift computation, where only its magnitude (leading bit) matters — see Engine Notes.

radius_world (bytes 2..4) is the same radius expressed in world units: where nonzero it equals radius * 2^(scale-8) exactly in 93/134 files, with authoring drift in the rest (ratios 1.4–4.8). It is zero in 1141/1275 shapes — every aircraft and weapon — and nonzero only for ground/naval scenery (ships, runways, cities, rocks, SAM sites). No the game executable code reads it: it is authoring residue — see Engine Notes for the tracing evidence.

Scale table (world_coord_feet = raw_i16 * scale_factor):

scale field scale_factor Notes
7 0.5 USNF97 and earlier only
8 1.0 Standard FA -- 1 unit = 1 foot
9 2.0 Large objects
10 4.0 Very large objects
11 8.0 Terrain features
0 1.0 Treated as 8

Instruction Dispatch

Instructions are either Byte-magic (1-byte opcode) or Word-magic (2-byte opcode: [op_byte, 0x00]). Dispatch is on the first byte.

Key instructions:

First byte Name Total size Notes
0xFF Header 14 Always first; scale at [6..8]
0x00 EndObject all remaining Triggers X86Unknown skip if obj_end_off set
0x01 EndShape all remaining Terminates the shape
0x1E ShortEOF 1+ Return from the current call frame (do_short_eof = ret); trailing 0x1E run is alignment. See Engine Notes
0x38 ShortJump 3 [38][rel16]; see Engine Notes
0xBC UnkBC 2
0xF0 X86Code variable Bail out -- x86 machine code
0xF6 VertexInfo 7 [F6][idx u16][color u8][normal i8[3]]
0xFC Face variable See face format section

Word-magic instructions (second byte = 0x00):

First byte Name Total size
0x06 OrderPlane 16 + u16@[14] — plane-test draw-order selector; see Engine Notes
0x08 Unk08 4
0x0C OrderPlaneYZ 12 + u16@[10] — two-axis (y/z) OrderPlane variant
0x0E OrderPlaneXZ 12 + u16@[10] — two-axis (x/z) OrderPlane variant
0x10 OrderPlaneXY 12 + u16@[10] — two-axis (x/y) OrderPlane variant
0x12 Unmask 4
0x2E Unk2E 4
0x3A Unk3A 6
0x40 JumpToFrame 4 + u16@[2]*2
0x42 SourceName 2 + strlen + 1
0x44 Unk44 4
0x46 Unk46 2
0x48 Jump 4
0x4E Unk4E 2
0x50 LongJump 6
0x66 Unk66 10
0x68 Unk68 8
0x6C OrderField 13/14/16 (trailing embedded 38/48/50 jump) — object-field draw-order selector
0x6E UnmaskLong 6
0x72 Unk72 4
0x76 Unk76 10
0x78 BBoxCull 12
0x7A Unk7A 10
0x82 VertexBuffer 6 + u16@[2]*6
0x96 Unk96 6
0xA6 JumpToDetail 6
0xAC JumpToDamage 4
0xB2 UnkB2 2
0xB8 UnkB8 4
0xC4 XformUnmask 16
0xC6 XformUnmaskLong 18
0xC8 JumpToLOD 8
0xCA UnkCA 4
0xCE UnkCE 40
0xD0 UnkD0 4
0xD2 UnkD2 8
0xDA UnkDA 4
0xDC UnkDC 12
0xE0 TextureIndex 4
0xE2 TextureFile 16
0xE4 UnkE4 20
0xE6 UnkE6 10
0xE8 UnkE8 6
0xEA UnkEA 8
0xEE UnkEE 2
0xF2 PtrToObjEnd 4

VertexBuffer (0x82 0x00)

Pushes a batch of vertices into the global vertex pool.

[82 00]           opcode (2 bytes)
[nverts u16]      number of vertices in this buffer
[push_at u16]     byte offset into the global pool where this buffer starts
[x y z i16 ...]   nverts * 3 signed 16-bit coordinates (LE)

Pool index = push_at / 8. Vertex slot size is 8 bytes in the engine's pool (6 bytes of coords + 2 bytes alignment padding), so push_at is always a multiple of 8. Face indices reference global pool indices.

TextureFile (0xE2 0x00)

Sets the current texture for subsequent faces.

[E2 00]           opcode
[name 14 bytes]   null-padded ASCII filename (e.g. "_A10.PIC")

PtrToObjEnd (0xF2 0x00)

Records the absolute code-section byte offset of the EndObject instruction.

[F2 00]           opcode
[offset u16]      absolute byte offset within the code section

Face (0xFC)

Variable-length polygon face instruction.

[FC]
[content_flags u8]   see FaceContentFlags below
[layout_flags u8]    see FaceLayoutFlags below
[color u8]           palette color index for untextured rendering
[is_shadow u8]       non-zero if this face is a shadow polygon

[if HAVE_FACE_NORMAL (content_flags & 0x40):]
    [face_normal i16[3]]    face normal vector, scale by 1/32765.0 to get float
    [if USE_BYTE_FACE_CENTER (layout_flags & 0x02):]
        [face_center i8[3]]
    [else:]
        [face_center i16[3]]

[nindices u8]           number of vertex indices (= number of polygon corners)

[if USE_SHORT_INDICES (layout_flags & 0x04):]
    [indices u16[nindices]]  2-byte pool indices
[else:]
    [indices u8[nindices]]   1-byte pool indices

[if HAVE_TEXCOORDS (content_flags & 0x04):]
    [if USE_BYTE_TEXCOORDS (layout_flags & 0x01):]
        [(s u8, t u8) * nindices]    8-bit texcoords
    [else:]
        [(s u16, t u16) * nindices]  16-bit texcoords

FaceContentFlags:

Bit Mask Meaning
7 0x80 Unknown (Unk1)
6 0x40 HAVE_FACE_NORMAL
5 0x20 Unknown (Unk2 — brighter shading)
4 0x10 Unknown (Unk3 — perspective-correct mapping)
3 0x08 Unknown (Unk4)
2 0x04 HAVE_TEXCOORDS
1 0x02 FILL_BACKGROUND
0 0x01 Unknown (Unk5)

FaceLayoutFlags:

Bit Mask Meaning
3 0x08 Unknown (Unk0)
2 0x04 USE_SHORT_INDICES (u16 instead of u8)
1 0x02 USE_BYTE_FACE_CENTER (i8[3] instead of i16[3])
0 0x01 USE_BYTE_TEXCOORDS (u8[2] instead of u16[2])

X86Unknown Region

Some shapes embed native x86 machine code in the instruction stream, entered by the 0xF0 X86Code opcode. These blocks are not procedural geometry generators — they are conditional selectors: each reads a piece of live game state and re-enters the bytecode interpreter on the sub-stream that matches. They exist because the bytecode's own conditionals cannot read arbitrary engine globals; the authoring tool emitted a small x86 switch instead. 208 of the 1275 FA_2.LIB shapes carry them.

The fx read codec bounds these regions (skip protocol below) and recovers the guarded sub-stream geometry via the PE relocation table (sub-stream harvest below) so articulated models render more completely; this section specifies the runtime contract so the game-accurate, state-selected effect can be reimplemented — see Round-Trip Notes and fa-bridge#21 for the interpreter that reads live state, and #297 for the codec-side work remaining.

Skip protocol (read codec)

The parser bounds each region:

  1. PtrToObjEnd (0xF2) seen: record obj_end_off = offset_field.
  2. EndObject (0x00) seen while current_pos < obj_end_off: the range [current_pos .. obj_end_off) is the x86 region (native code plus the sub-stream geometry it guards). Skip to obj_end_off and continue.
  3. EndObject (0x00) seen while current_pos >= obj_end_off: real end of object; stop parsing.

Sub-stream harvest (read codec, reloc-based)

Rather than dropping the guarded geometry, the codec recovers it without executing the x86. Each x86 selector sets esi to a sub-stream via an internal pointer that the Phar Lap PE base-relocation table fixes up, so the reloc entries whose target lands inside the code section are the exact sub-stream entry offsets (a byte scan would false-positive on x86 bytes — the relocations do not). The codec (lib/src/sh.cpp, collect_reloc_targets + harvest_target):

  1. Parses the .reloc table; keeps targets that point into the code section and are not FF 25 trampolines (those reach the game executable's exports).
  2. From each target, walks the geometry (VertexBuffer / Face / TextureFile, plus VertexInfo) structurally: it follows Unmask/UnmaskLong and draw-order-selector calls into their fragments (which return at their ShortEOF), follows 0x48/0x38 jumps, and applies the same frame/damage/LOD/detail selection as the base walk (bounded by a visited-set + recursion depth). Because some fragments are only reached by calls the static walk cannot decode (x86, object-field conditions), a reloc-entered walk also continues through top-level ShortEOFs instead of stopping — the walk-through that recovers a complete airframe (the A-10's left wing, the F-16's full planform).
  3. Writes vertices append-only (never below the base pool count) so state-variant sub-streams that reuse low pool slots cannot corrupt the base mesh. When a VertexBuffer is skipped by that guard, the faces that follow it in the same walk are dropped too — they index the skipped buffer, and collecting them drew coarse-LOD faces with finest-pool vertices (giant garbage polygons).

By default this yields the base mesh plus every reloc-reachable sub-stream — all articulation states merged. The codec now also recovers, clean-room from each shape's own bytes, which _PL* input every selector reads and which sub-stream a given compare value selects, so a caller can render one discrete state instead of the merge:

  • Input identification — the _PL* names are present in the shape's own .idata import table; each FF 25 <IAT> trampoline binds a code address to an import, and a base relocation pins the cmp word [trampoline], imm8 operand, so the input the block compares is read directly (relocations anchor the parse — a raw byte scan would false-positive on x86, the fixups do not).
  • Sub-stream identification — the relocation-pinned code pointers in a block that are neither trampolines nor the head of another block are its guarded sub-stream entries; each is tagged with the governing (input, compare value).
  • Contiguous-run attribution — a moving part is one run of F0 blocks that abut (each block's byte range ends where the next begins), because the engine's x86 falls straight through the chain. The cmp [trampoline], imm therefore governs the sub-streams of the following blocks in the run too — including blocks that carry no compare of their own: a case's geometry commonly lands in a trailing no-cmp block (e.g. the A10 gear guards spread across a cmp-block → cmp-block → geometry-block run). The last-seen (input, value) is carried forward across the run and reset at a run break — a block whose head does not abut the previous block's end, i.e. real geometry sits between them. Attributing only the cmp-block's own pointers (a stub) left the geometry un-guarded, so a non-matching selection failed to hide it (#443).
  • Selectionfx::ShState::articulation[input] = value emits only the sub-streams whose case matches; an unset input keeps the merged default. Only the shape's own compare immediates are read; the per-shape case→variant meaning is documented by OpenFA and not transcribed.

This is #295 (expose the inputs — fx::sh_articulations, ShInfo::articulations) and the discrete half of #297. The parametric patch some blocks also apply (e.g. _PLgearPos interpolated into the gear geometry) is not reproduced — discrete states render at their authored position.

Entry contract (0xF0 → native)

do_start_asm (0x4D4254), the 0xF0 handler, is two instructions:

push esi        ; esi = the bytecode pointer, now just past the F0 00 opcode —
ret             ; i.e. the address of the embedded x86 itself. RET jumps to it.

So the interpreter transfers to the payload by push esi; ret, entering native execution at the current stream position with esi pointing at the payload. The x86 inherits the interpreter's register and memory state (the shared vertex pool, view matrix, and viewer-relative position globals used elsewhere in Engine Notes); it uses esi for position-independent access to the sub-streams that follow it.

External references (trampolines)

The payload reaches the game executable globals and functions through trampolines — 6-byte indirect jumps [FF 25][target u32] whose target the shape's Phar Lap PE relocations bind to a the game executable export by name at load time. Two kinds:

  • Inputs — a global the block reads. Across the corpus these are dominated by the _PL* articulation-state block that ShapeSetup initializes (independently confirmed: the same symbols appear as writers there and as trampoline reads here):
Trampoline Shapes Selects
_PLgearDown / _PLgearPos 126 / 103 landing-gear geometry
_PLrightFlap / _PLleftFlap 104 / 104 flap geometry per side
_PLafterBurner 85 exhaust/afterburner geometry
_PLbrake 75 airbrake geometry
_PLrudder 73 rudder deflection geometry
_PLhook 17 arrestor-hook geometry
_PLswingWing 15 variable-sweep wing geometry
_PLcanardPos 14 canard geometry
_PLbayOpen / _PLbayDoorPos 10 / 6 weapons-bay doors
_PLvtOn / _PLvtAngle 4 / 1 thrust-vectoring nozzles
_PLslats 3 leading-edge slats
_effects / _effectsAllowed 23 / 31 render-effect gating
brentObjId, _SAMcount, @HARDNumLoaded@8 12, — , 4 effect shapes (e.g. FIRE.SH): draw driven by object id / live counts
  • Callbackdo_start_interp (0x4D4240), the bytecode entry. All 208 blocks reference it. The payload sets esi to the selected sub-stream and jumps here to resume interpreting that geometry.

Runtime behavior (switch → re-enter)

Each block is therefore:

read  <input global>                 ; via an FF25 trampoline
switch on its value:
  case k0: esi = &substream_0
  case k1: esi = &substream_1
  ...
goto do_start_interp                 ; interpret the selected sub-stream

The value→variant mapping is per-shape (e.g. _PLgearDown: 0 = retracted, 1 = extended, with 4 = strut on F117.SH). The exhaustive decode of those case values is documented by the OpenFA project's sh crate (GPLv3) as a symbol→state table (_PLgearDown/_PLrightFlap/_PLslats/_PLbayOpen/_PLbrake/_PLhook/…); the mechanism, trampoline inventory, and entry contract here are independently derived (Ghidra on the game executable plus a structural parse of all 1275 shapes) and the per-shape case values are attributed to OpenFA per the license boundary — never transcribed.

Implementing this (fa-bridge#21, clean-room): for each block, read the named global, map its value to a sub-stream via the state tables, and interpret that sub-stream. The original x86 need never execute — the switch is what matters.

Inventory

Metric Count Source
Shapes embedding x86 blocks (reference do_start_interp) 208 structural parse
…reading _PL* articulation state 134 structural parse
…reading effect state (brentObjId/_SAMcount/@HARDNumLoaded@8) 12 structural parse
Shapes producing no static OBJ geometry (x86 gates everything) 65 fx codec (Round-Trip Notes)

The 65 fully-gated shapes are procedural effects (FIRE.SH, FLARE.SH, DEBRIS.SH, EXP.SH, CLOUD*.SH, …) and a few complex models (AC130.SH); the rest are articulated aircraft whose base mesh extracts normally but whose moving-part variants sit behind these switches. The evidence script is AnalyzeSHX86.java.

.PTS distribution files

Community mod archives sometimes distribute aircraft shadow/crash shapes as .PTS files (e.g. A10.PTS) rather than the in-LIB convention of A10_S.SH. The binary format is identical — parse with the same SH parser. The shadow_shape ptr in the corresponding .PT file points to the _S.SH name; the .PTS rename is a distribution artifact only. (Unrelated to the in-LIB PTS overlay DLL format documented in PTS.md.)

Cross-validation against OpenFA — audited

The instruction inventory above was audited against the OpenFA sh crate (GPLv3; commit 7507fef5, 2024-10-29). Attribution: the mnemonic names used here (Header, ShortEOF, Unmask, XformUnmask, JumpToFrame/Detail/Damage/LOD, VertexBuffer, VertexInfo, SourceName, TextureFile/Index, PtrToObjEnd, EndObject/EndShape, X86Code, and the Unk* scheme) originate from OpenFA's reverse engineering. Facts are documented here with attribution; no code crosses the license boundary — sizes and formulas were independently re-validated by fx_lib's parser walking all 1275 FA_2.LIB shapes with zero errors.

Agreement — the two inventories are identical on every checkable fact:

  • Same 55 opcodes; neither project knows an opcode the other lacks.
  • All fixed sizes match, including the full Unk* set (0x08…0xEE).
  • All variable-size formulas match: 0x06 = 16 + u16@[14], 0x0C/0x0E/0x10 = 12 + u16@[10], 0x40 = 4 + u16@[2]*2, 0x42 = 2 + strlen + 1, 0x82 = 6 + u16@[2]*6, and the 0x6C flag arms (0x38→13, 0x48→14, 0x50→16).
  • Face content/layout flag bits, the header layout, the scale table (including USNF97-only 7 → 0.5 and 0 → 1.0), and the push_at % 8 == 0 vertex-pool constraint all match.

Recorded differences (both inert for our export-only parser):

Topic This spec OpenFA Note
0xF0 magic class byte-magic word-magic (F0 00) both immediately delegate to x86 handling, so no parse divergence; adjudicated: the engine has no magic-class concept at all — dispatch is uniform vector_table[opcode*2] (see Engine Notes)
EndObject extent consumes all remaining input, with the PtrToObjEnd/EndObject skip protocol for x86 regions 18-byte errata heuristic, because OpenFA parses through x86 regions via trampolines instead of skipping them different models of the same stream behavior; ours is validated by the 1275/1275 walk

Where this spec now exceeds OpenFA: the two header fields OpenFA marks unknown ("probably super important, but I don't know what they mean") are resolved as radius / radius_world by engine tracing — see Engine Notes — and OpenFA's in-source hypotheses for the formerly open opcodes are now adjudicated by the handler decompiles: 0xC8 is the distance LOD branch, and the 0x6C/0x06-family ops OpenFA suspected of "manual backface culling" are the draw-order selectors (both chains always render; the plane/dot-product test only swaps the order — close to, but not, a culling gate). 0x1E, which OpenFA treats as a NOP pad, is the fragment return (do_short_eof = ret).

Engine Notes

Shape opcodes that branch on entity state are handled by the game executable functions. Confirmed from FA.SMS:

VA Symbol Description
0x4D22D4 do_ifdestroyed Tests whether the entity is in destroyed state; skips or follows a conditional branch in the shape bytecode stream
0x4D057C GRAddBrentObj Registers a shape instance for rendering: consumes the header (radius, scale), culls, computes the projection shift, and queues a render-sort record whose stream pointer starts at header+0xe

Header field consumption (traced)

GRAddBrentObj(shape, x, y, z) is the only the game executable code that touches the raw 14-byte header:

  • scale (+6) shifts the viewer-relative Δx/Δy/Δz into shape units.
  • radius (+4) is OR-ed with those shifted magnitudes before the _shift_table leading-bit lookup that selects the projection/precision shift — flooring the shift so the whole shape stays in 16-bit range — and is copied into the render-sort record (+0x10). Only the leading bit matters, which is why authored values need not be exact radii.
  • radius_world (+2) is never read. Evidence: the interpreter receives the stream pointer already advanced to header+0xe; a scan of every decompiled the game executable function found no 16-bit or 32-bit read of header+2 co-occurring with other header-field access; and a raw-listing scan of the hand-written interpreter region (0x4CD000–0x4D7000, including 5342 instructions outside Ghidra functions) shows all 467 [reg+0x2] accesses are word-magic operand fetches and zero negative-displacement reads that could reach back from the stream start (AnalyzeSHHeader.java).

Interpreter dispatch — vector_table (traced)

The shape interpreter is hand-written threaded code: there is no central decode loop. Every handler ends by fetching and dispatching the next instruction inline (150+ dispatch sites across 0x4C9581–0x4D6F42):

mov   ax, [esi]                  ; AL = opcode, AH = first operand byte
lea   esi, [esi+2]               ; consume 2 bytes
movzx ebx, al
jmp   [vector_table + ebx*2]     ; 4-byte entries at opcode*2

vector_table (the FA.SMS name; .data, 0x5183A0) holds 128 dword handler pointers addressed at opcode*2 — so only even opcodes dispatch cleanly, and the parser-side byte-magic/word-magic distinction dissolves at engine level: every fetch consumes [op][first-operand-byte], and handlers whose operands start at byte 1 back up themselves (the 3-byte 0x38 handler begins with DEC ESI). 0xFF/0x01 never reach the table in well-formed streams (the header is consumed at queue time by GRAddBrentObj).

Handler symbols recovered from FA.SMS (the full 128-entry map is reproduced by AnalyzeSHDispatch.java, which also materializes the handlers as Ghidra functions):

Op Spec name Handler FA.SMS symbol
0x1E ShortEOF 0x4D17F4 do_short_eof — plain ret: returns from the current call frame (fragment end)
0x38 ShortJump 0x4D30E4 do_short_ijmpDEC ESI, then falls into the 0x48 Jump handler
0x40 JumpToFrame 0x4D3134 do_anim_jmp
0x42 SourceName 0x4D17BC do_shape_name
0x44 Unk44 0x4D42EC do_setcoarse
0x46 Unk46 0x4D478C do_force_no_pmap
0x50 LongJump 0x4D3100 do_ijmp_long[50 00][rel32], unconditional esi += rel32
0x6E UnmaskLong 0x4D22A8 do_sfcal_long
0xA6 JumpToDetail 0x4D2318 (unnamed) — skips rel16 when detail global 0x515EEE ≥ operand threshold
0xAC JumpToDamage 0x4D22D4 do_ifdestroyedesi += rel16 when _destroyed (0x50C39C, set per entity by ShapeSetup) is non-zero
0xB2 UnkB2 0x4D2344 do_use_terrain_detail
0xB8 UnkB8 0x4D22FC do_no_overlap
0xC6 XformUnmaskLong 0x4D33D8 do_icall_long
0xC8 JumpToLOD 0x4D416C do_jumpfar4 — skips its 6-byte operand when flag 0x515EF0 & 0x20000; otherwise computes a view-space depth dot product for the distance test
0xCE UnkCE 0x4D47A4 do_streamer_def
0xD0 UnkD0 0x4D47B8 do_streamer_draw
0xD2 UnkD2 0x4D4894 do_screen_coords
0xDA UnkDA 0x4D42C8 do_setlight
0xE4 UnkE4 0x4D4A47 do_brush_area
0xE6 UnkE6 0x4D4A6D do_brush_area_full
0xE8 UnkE8 0x4D5475 do_new_smap
0xEA UnkEA 0x4D5644 do_new_rmap
0xEE UnkEE 0x4D4A30 do_brush_trans
0xF0 X86Code 0x4D4254 do_start_asm
0xF2 PtrToObjEnd 0x4D4258 do_collision_info
0xF6 VertexInfo 0x4D4308 do_set_point_color
0xFC Face 0x4D43DC do_new_poly

Engine-only opcodes (handlers exist; absent from the 1275-file FA corpus): 0x34do_nop, 0x36/0x3Edo_new_pmap_or_tmap, 0x5Cdo_setcolor2, 0xECdo_brush_solid, 0xF4do_set_gouraud, 0xF8do_drawobj000, 0xFAdo_fullpntg16, 0xFEdo_nt. Six slots (0x14, 0x16, 0x3C, 0xA8, 0xAA, 0xC0) share do_if_not_effect — a conditional skip keyed on the effects setting — and ten unassigned slots point at a common stub (0x4D17E0).

These handler names are FA.SMS facts. The animation and LOD/damage families are specified below.

Animation opcodes (traced)

Shapes animate by free-running frame selection against a single global counter — there is no per-entity animation clock, start trigger, or authored playback rate.

0x40 JumpToFrame — do_anim_jmp (0x4D3134). Layout [40 00][nframes u16][rel16 × nframes] (total 4 + nframes*2, matching the skip-table formula). Runtime:

idx    = _frameCounter mod nframes
target = &frame_table[idx] + (int16)frame_table[idx]   ; rel16 is relative
                                                        ; to its own slot

_frameCounter (0x4EB738) is a global incremented once per rendered frame in the main flight loop (FlyingLoop, 0x404C70) as _frameCounter = (_frameCounter + 1) & 0x7FFF, and reset to 0 on screen transitions. Because selection is _frameCounter mod nframes, every animated model is phase-locked to the same counter: a shape with nframes = 8 advances one frame per rendered frame and repeats every 8 frames, independent of which entity draws it. Used by 510/1275 shapes (5610 opcodes).

Playback (for fa-bridge#19): keep a render-tick counter masked to 15 bits; at each JumpToFrame compute counter % nframes, read that slot's signed rel16, and branch to slot_address + rel16.

Control-flow primitives (shared by the animation and LOD/damage families — each advances the instruction pointer esi; rel is signed and, except where noted, relative to the end of its own operand):

Op Name Layout Behavior Corpus
0x48 Jump (0x4D30E5) [48 00][rel16] esi += rel16 533 files
0x38 ShortJump — do_short_ijmp (0x4D30E4) [38][rel16] (3 bytes) identical to Jump; handler does DEC ESI then shares the 0x48 body 724 files
0x50 LongJump — do_ijmp_long (0x4D3100) [50 00][rel32] esi += rel32 0 (engine-only)

Fragment calls and draw-order selectors (traced)

A shape's stream is not a linear list — it is a web of fragments linked by call-and-continue selector opcodes. Three pieces make the structure:

0x1E ShortEOF — do_short_eof (0x4D17F4). The handler is a plain ret: it pops the current interpreter call frame. A fragment invoked by any of the call-form opcodes below ends at its ShortEOF and control resumes in the caller; at top level it ends the object. A run of 0x1E bytes is alignment after the return — only the first ever executes. (Previously read as a NOP pad; that misreading is what made static walks merge every alternative block.)

0x12 Unmask — do_unmask (0x4D2278) (728 files) and UnmaskLong (0x6E, unused) call the referenced sub-stream through the dispatch table's call form (FF 14, versus FF 24 for the normal tail jump): the callee chain runs until its ShortEOF rets, then control resumes after the opcode. The common idiom is [12 → body][38 → join][body…, 1E] — call the inline body, then jump past it.

Draw-order selectors — the mid-stream conditionals. Each evaluates a condition, then renders both of its sub-chains: it calls one (which returns at its ShortEOF) and tail-continues at the other; the condition only swaps which is drawn first. They are painter's-algorithm ordering for overlapping parts, not visibility gates — a static reader must follow both links:

Op Handler Condition Call target Continue
0x6C OrderField sh_op_6C (0x4D23AC) object-record field [w0] vs w1 opd + w3 + 8 opd + 6 + w2
0x06 OrderPlane sh_op_06 (0x4D2450) sign of nx·(x+xv) + ny·(y+yv) + nz·(z+zv) opd + 16 + rel next instruction
0x0C/0x0E/0x10 sh_op_0C/0E/10 two-axis plane sign (y/z, x/z, x/y) opd + 12 + rel next instruction

(opd = operand start, two bytes past the opcode. For 0x6C the 13/14/16-byte sizes are a trailing embedded 38/48/50 jump; for the plane forms the size-field u16 doubles as the continue link, which is why their total size is base + u16.) A fine-detail airframe is typically one long chain of these nodes — the A-10's fuselage, nose, and tail fragments are all reached through 0x06/0x6C links, not linear fall-through.

Sub-model draw (articulated parts). XformUnmask (0xC4, 2 files) / XformUnmaskLong (0xC6, unused) work like Unmask but first save the view matrix and object-position globals and apply the operand offsets to the object position, so the sub-stream renders at a relative transform — the attached-part mechanism. Their per-operand layout is only partially traced and stays in Open Questions; they are not part of frame playback.

LOD and damage-state opcodes (traced)

Three conditional branches choose which geometry block renders. Each jumps forward to an alternative block when its condition selects it; the fall-through (default) block is the primary geometry. All rel16 are signed and relative to the end of the operand.

0xAC JumpToDamage — do_ifdestroyed (0x4D22D4). Layout [AC 00][rel16].

if (_destroyed != 0) esi += rel16     ; branch to the damaged sub-model
                                       ; else fall through to intact geometry

_destroyed (0x50C39C) is set per entity by ShapeSetup (0x4AB450) as the shape is queued: it is (health_word == 0) read from the object record (+0xE), with a _forceDestroyed override forcing 1. So the damaged geometry is authored inline after the opcode and reached only for destroyed entities. Rare — 62/1275 shapes (65 opcodes), almost all ground/naval targets (e.g. BARKSA.SH).

0xA6 JumpToDetail (0x4D2318). Layout [A6 00][rel16][threshold u16].

if (_detail < threshold) esi += rel16  ; branch to the lower-detail block
                                        ; else fall through to full detail

A static quality switch on the user's detail preference _detail (0x515EEE), independent of distance. Near-universal: 1094/1275 shapes.

0xC8 JumpToLOD — do_jumpfar4 (0x4D416C). Layout [C8 00][size u16][pixel_threshold u16][rel16] (6-byte operand → total 8). Distance-based. When the force-max-detail flag _effects & 0x20000 is set, the operand is skipped and the block always renders; otherwise the handler compares the object's projected on-screen size against the threshold:

depth     = -(xv·m3 + yv·m6 + zv·m9)              ; view-space Z, clamped >= 20
projected = (size * aspecty * scrh) / depth / 2   ; ~ vertical pixels on screen
threshold = (pixel_threshold * sizeAdjust) >> 8
if (projected < threshold) esi += rel16           ; too small -> next (coarser) LOD
                                                   ; else fall through to this LOD

with xv/yv/zv (0x51CDAA/AC/AE) the object's viewer-relative position, m3/m6/m9 (0x515F48/4E/54) the view-matrix depth row, aspecty (0x51837C), scrh (0x5182F4) the screen height, and sizeAdjust (0x51D125) a global tuning factor. LOD blocks chain: each JumpToLOD renders its block when the object is large enough on screen, else jumps past it to the next, coarser block. Near-universal: 1098/1275 shapes (2064 opcodes).

State-selected rendering (read codec)

sh_parse_mesh(data, size, ShState{}) executes the stream structurally — following unconditional jumps (0x48/0x38), calling Unmask and draw-order selector fragments (which return at their ShortEOF), and branching each conditional the way the engine would for one selected state — so it emits one state's geometry rather than every block merged. ShState selects:

  • destroyed0xAC JumpToDamage: false (default) falls through to the intact geometry; true takes the branch into the damaged sub-model. ShMesh::has_damage reports whether the shape carries an inline branch at all — aircraft usually don't (their destruction is the whole-model _A_D swap, shape-selection.md); sh_variant_name derives those sibling names for callers that resolve the swap themselves.
  • frame — the 0x40 JumpToFrame index. The codec computes idx = frame mod nframes per opcode and branches to that slot (the same slot_address + rel16 the interpreter uses, with frame standing in for _frameCounter), and reports the model's animation length as ShMesh::frame_count (the max nframes seen; 0 = static).
  • lod — the 0xC8 JumpToLOD level: 0 (default) = finest … ShMesh::lod_count - 1 = coarsest. The engine compares the object's projected on-screen size to each site's pixel threshold; the codec stands in a synthetic size — level 0 exceeds every threshold (all sites fall through to their finest block), level k sits just below the k-th largest distinct threshold in the shape, so exactly the engine's blocks for that apparent size render. lod_count = distinct thresholds + 1.
  • detail — the 0xA6 JumpToDetail preference word (the engine's _detail): a site branches to its lower-detail block when detail < threshold. Defaults to maximum (every full-detail block); ShMesh::has_detail reports whether any site exists.

Frame selection is applied in the base stream, inside called fragments, and inside the x86-gated sub-streams recovered by the reloc harvest (the F-16 class keeps its 0x40 table behind control flow the harvest doesn't follow and stays frame_count = 0). The reloc harvest itself runs only for the finest intact path (lod == 0, no detail/LOD branch taken): its sub-streams are authored against the finest vertex pool, and a harvest walk that skips a VertexBuffer to protect the pool also drops the faces that follow it — they index the skipped buffer (this is what previously drew coarse-LOD faces with finest-pool vertices: the giant garbage polygons). The fxs preview exposes a Destroyed toggle, a Frame slider (frame_count > 1), a LOD slider (lod_count > 1), and a Low detail checkbox (has_detail) — docs/gui.md.

Round-Trip Notes

The codec is deliberately export-only: OBJ→SH is out of scope because a shape is a bytecode program (animation frames, LOD variants, damage states, embedded x86), not a plain mesh — regenerating one from a static OBJ would discard the behavioral stream.

Extraction coverage, tested against all 1275 .SH files from FA_2.LIB:

Result Count %
Vertices + faces extracted 1257 98.6%
No geometry (no OBJ output) 18 1.4%
Parser crash / error 0 0%

(The counts are for the default state — finest LOD, full detail, intact, frame 0. Since the structural walk landed, per-shape face counts are one coherent state, no longer the union of every LOD/frame/damage block.)

The remaining no-geometry files are pure procedural effects that emit their geometry entirely from x86 (no static VertexBuffer/Face at all): FIRE.SH, FLARE.SH, BULLET.SH, CHAFF.SH, CLOUD*.SH, CRATER.SH, DEBRIS.SH, EXP.SH, EJECT.SH, etc. (Complex airframes that were previously x86-only, such as AC130.SH, now recover their facets via the walk-through harvest above.)

Shadow models (*_S.SH): flat ground silhouettes, Z=0, typically 6-20 faces.

Sample results:

File Scale Verts Faces (state 0) LODs Textures
A10.SH 8 (1x) 425 377 3 (377/63/10 faces) _a10.PIC
A10_S.SH 8 (1x) 21 6 1 (none)
F22.SH 8 (1x) 452 407 3
F15E.SH 8 (1x) 483 556 3
AC130.SH 9 (2x) 283 335 3
HANGAR.SH 8 (1x) 10 6 2
TREEA.SH 8 (1x) 19 11 3 _treea.PIC

Texture coordinates. Faces with HAVE_TEXCOORDS carry one (s, t) per corner (ShFace::texcoords, parallel to indices); the codec extracts them in texel space (origin top-left, pixels of the referenced PIC) since the shape does not record its texture's dimensions. sh_to_obj emits them as vt lines with f v/vt faces; a consumer normalizes by the PIC's width/height (and flips V) for a 0..1 sampler. Example: A10.SH — 202/377 faces textured against _a10.PIC.

Further limitations: sh_to_obj exports whichever single state was parsed (the default ShState — finest LOD, full detail, intact, frame 0 — unless the caller selected otherwise via the in-memory parse; see State-selected rendering). The articulation variants merged by the reloc harvest (gear up and down, …) remain undistinguished within that state, and there is no CLI flag to pick a state for export yet.

Open Questions

1. Remaining Unk* opcode semantics

All opcodes have confirmed sizes (the parser walks every FA shape without error). The control-flow families are now traced — animation (0x40, 0x48, 0x38, 0x50; see Animation opcodes), LOD/damage (0xA6, 0xAC, 0xC8; see LOD and damage-state opcodes), and the fragment-call structure (0x1E ShortEOF, 0x12/0x6E Unmask calls, the 0x06/0x0C/0x0E/0x10/0x6C draw-order selectors; see Fragment calls and draw-order selectors) — as is the dispatch mechanism that named a dozen render-state handlers. What remains untraced is the render-state / attribute set (do_setcoarse, do_setlight, the do_brush_* and do_new_smap/do_new_rmap family, the streamer pair, and the remaining Unk* word-magic entries), the exact per-operand layout of the Xform sub-model calls (0xC4/0xC6), and the unknown Face flag bits. These affect surface appearance, not geometry or the animation/LOD/damage playback contract.

Status: open — re-static (#52)

2. x86-embedded geometry regions

The runtime contract of these regions is specified in X86Unknown Region: the 0xF0 → push esi; ret entry, the FF25 trampoline reads of _PL*/effect globals, and the do_start_interp re-entry that selects a geometry sub-stream. What remains is not a spec gap but a codec limitation: the static fx read path skips these regions, so the 65 fully-gated shapes still export no OBJ (see Round-Trip Notes), and the exhaustive per-shape case-value tables are carried with attribution to OpenFA rather than re-derived here.

Status: specified — implementable by fa-bridge#21 (closed #125)

Formats: PIC_-prefixed skin textures referenced by TextureFile; LIB — container (FA_2.LIB ×1275); PT — flight-model records whose shadow_shape field names the _S.SH shadow shapes.

Engine: shape-selection.md — how the engine picks which .SH to draw (the whole-model damage swap and per-class _A_D variant set), the inter-shape counterpart to this file's intra-shape LOD/damage opcodes; renderer.md — the shape interpreter and rasterizer pipeline; architecture.md — Phar Lap overlay loading.