Crusader_Decomp/docs/combat-dat.md
2026-04-05 18:27:09 +02:00

16 KiB

COMBAT.DAT

Scope

This note documents the shipped COMBAT.DAT used by the Crusader builds in this workspace.

Verified corpus facts:

  • STATIC/COMBAT.DAT, STATIC_1.01/COMBAT.DAT, STATIC_DEMO/COMBAT.DAT, STATIC_JP/COMBAT.DAT, STATIC_REGRET/COMBAT.DAT, and STATIC_REGRET_DEMO/COMBAT.DAT are byte-identical.
  • All six files are 1734 bytes and share SHA-256 c6097a721141a2e66ec6d0cd578427305f28ae9efe8a206bd5cf946ce2075faf.
  • ScummVM's Crusader support code and the live CRUSADER.EXE database agree that this file drives NPC ranged-combat tactics through a small bytecode interpreter.

This note uses three evidence sources together:

  • direct binary parsing of the shipped COMBAT.DAT
  • live CRUSADER.EXE function/type names already present in the workspace export map
  • ScummVM Crusader-source behavior as a readable reference model for the tactic interpreter

High-Level Findings

  • The file is a small FLEX-style archive with 14 populated tactic records.
  • Each tactic record has a zero-padded 16-byte name, followed by four uint16 block offsets, followed by bytecode.
  • In the shipped data, only block 0 and block 1 matter. Block 0 acts like an entry/setup phase. Block 1 acts like the steady-state loop. The third and fourth offset slots are present but only contain near-EOF placeholder values in this file.
  • The tactic bytecode is not just a name table. It is real AI scripting: face target, move, pathfind, test line-of-fire, loop, jump, sleep, and switch tactic/block.
  • Several tactics are waypoint-driven. ScummVM identifies opcode 0xA6 as a search for nearby shape 0x33A marker objects by frame number; that matches the naming of OneToTwo, ThreeToFour, EggHopper1, and 123_Shoot.
  • There is one notable model discrepancy to keep in mind: the shipped archive contains a valid entry 0 named Dumb, but ScummVM still special-cases tactic number 0 as a generic built-in attack path rather than always interpreting COMBAT.DAT entry 0. Treat that as an open compatibility detail rather than silently preferring one model.

Archive Layout

Outer Container

Observed on-disk structure:

  • file size: 0x06c6
  • first populated record starts at 0x0280
  • archive directory begins at 0x0080
  • directory entries are 8 bytes each: <uint32 offset, uint32 length>
  • the shipped file leaves most directory slots empty and populates only 14 entries

Populated record table:

Index File offset Length Name
0 0x0280 0x004f Dumb
1 0x02cf 0x003f Pivot
2 0x030e 0x004f Advance
3 0x035d 0x0047 Mental
4 0x03a4 0x0052 Careful
5 0x03f6 0x004c OneToTwo
6 0x0442 0x004c ThreeToFour
7 0x048e 0x0074 EggHopper1
8 0x0502 0x0056 StepOutShootNE
9 0x0558 0x005c StepOutShootNW
10 0x05b4 0x0046 Random_CHAOS
11 0x05fa 0x003b Static_Chaos
12 0x0635 0x0043 Stand_Choas
13 0x0678 0x004e 123_Shoot

Two names preserve the shipped typos/spelling:

  • Stand_Choas
  • Random_CHAOS / Static_Chaos mixed casing

Inner Record Format

Per record:

Offset Size Meaning
0x00 16 ASCII tactic name, NUL-padded
0x10 2 block 0 start offset
0x12 2 block 1 start offset
0x14 2 block 2 start offset
0x16 2 block 3 start offset
0x18+ variable bytecode stream

Observed block-offset pattern:

  • Most tactics use block 0 at 0x002c and block 1 at 0x0031.
  • The waypoint tactics OneToTwo, ThreeToFour, and 123_Shoot have a longer setup block and therefore move block 1 farther forward.
  • The later two offset slots are usually 0x004d and 0x004e even when they point at the final sentinel bytes rather than real independent blocks.

ScummVM's comment in combat_dat.h says the format has 10x2-byte offsets, but both its constructor and the live executable logic only use four offsets. The shipped file matches the implementation, not the stale comment.

Live Executable Integration

The live CRUSADER.EXE export map already carries the important attack-process fields:

  • combatDatTacticPtr at attack-process offset 0x45
  • combatDatTacticPtr2 at 0x49
  • combatDatTacticCurOffset at 0x4d
  • combatDatBlockNo at 0x4f
  • tacticNo at 0x51

Relevant live helpers:

  • 1108:0586 Attack_SetupForTacticNo
  • 1108:0506 Attack_SetupForBlockNo
  • 10e8:3572 NPC_GetNPCTacticNo
  • 10e8:358c NPC_SetNPCTacticNo

Current best read of the runtime flow:

  1. NPC state stores a tactic number in ItemNPCData.field21_0x5c.
  2. Attack_SetupForTacticNo validates that the tactic slot has loaded COMBAT.DAT data, stores the selected tactic number in the attack process, and copies the far pointer to that archive entry into the process.
  3. Attack_SetupForBlockNo selects one of the record's four offset words and seeds combatDatTacticCurOffset from it.
  4. The attack-process main loop reads one opcode byte at a time from the selected block and executes it as AI logic.

ScummVM's Crusader attack-process code adds one more important detail:

  • readNextWordWithData() treats any immediate operand >= 33000 as an index into a 10-entry tactic-local variable array instead of as a literal value.

The shipped COMBAT.DAT in this workspace does not appear to use that variable indirection. All operands decoded here are direct literals.

Used Opcode Set

Only this subset is actually used by the shipped tactics:

Opcode Mnemonic Meaning
0x84 set_target_objid Set attack target object id
0x85 anim_walk Play walk/advance animation
0x88 turn_left_90 Turn 90 degrees left
0x89 turn_right_90 Turn 90 degrees right
0x8a fire_small_if_clear Fire if line-of-fire is valid
0x8d pathfind_home Pathfind to actor home position
0x8f pathfind_midpoint Pathfind to midpoint between actor and target
0x93 sleep_scaled Sleep for N ticks, scaled by difficulty
0x94 loiter Run a loiter sub-process for N ticks
0x95 face_target Turn toward target center
0x9a jump_if_dist_lt_481 Jump if target distance is under 481
0x9c jump_if_shot_blocked Jump if fireDistance() fails
0x9d jump_if_shot_clear Jump if fireDistance() succeeds
0x9f loop_begin Set loop counter and remember current stream position
0xa6 pathfind_marker_frame Find nearby shape 0x33a marker by frame and pathfind to it
0xa9 face_east Turn east
0xaa face_west Turn west
0xc0 jump Unconditional jump to stream offset
0xc1 loop_end Decrement loop counter and jump back while nonzero
0xff flip_to_block1_restart If still in block 0, switch to block 1; then restart current block

Practical interpretation of 0xff in shipped data:

  • In block 0, it is a one-way handoff into block 1.
  • In block 1, it behaves like restart block 1 forever.

That is why most tactics have a very short block 0: setup once, then live in block 1.

Tactic Catalog

0. Dumb

Role:

  • simplest mobile shooter with midpoint fallback

Decoded behavior:

  • block 0: target object 1, face target, then hand off to block 1
  • block 1:
    • if a shot is already clear, jump straight into a 3-iteration fire loop
    • otherwise pathfind to the midpoint between NPC and target, face target, and re-test line-of-fire
    • if still not clear after midpoint attempts, loiter briefly
    • once clear, loop 3 times: face target, fire, sleep 30
    • restart block 1

Human read:

  • close some distance toward a firing lane, then burst-fire three times

1. Pivot

Role:

  • stationary pivot-and-shoot burst

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • if line-of-fire is clear, enter a 3-shot loop immediately
    • loop body: face target, fire, sleep 30
    • restart

Human read:

  • no movement logic beyond facing the target; just turn and shoot in bursts

2. Advance

Role:

  • move forward between bursts if a clear shot is available; midpoint fallback otherwise

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • face target
    • if shot is blocked, jump to midpoint-reposition logic
    • otherwise run a 3-iteration loop: face target, fire, loop
    • sleep 30
    • play walk animation
    • restart
    • midpoint branch: pathfind midpoint, face target, re-test for clear shot, loiter briefly if still blocked, restart

Human read:

  • pressure forward when able to shoot, otherwise drift toward a midpoint lane until a shot opens

3. Mental

Role:

  • shoot if clear; otherwise midpoint hop and shoot

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • if shot is blocked, pathfind to midpoint first
    • run a 3-iteration fire loop
    • restart

Human read:

  • simpler than Advance: it does not include the extra walk/sleep pressure cycle, only burst-fire with midpoint correction

4. Careful

Role:

  • range-gated cautious shooter

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • if target distance is not under 481, loiter and restart
    • if within 481 and shot is clear, fire once and restart
    • if blocked, face target and test again
    • if still blocked, pathfind midpoint and test again
    • if still blocked, face target and test again
    • if still blocked after all of that, loiter briefly and restart

Human read:

  • only engages at medium/close range and spends more effort finding a clean shot before moving or firing

5. OneToTwo

Role:

  • shuttle between marker frame 1 and marker frame 2

Decoded behavior:

  • block 0:
    • target object 1
    • pathfind to marker frame 1
    • face target
    • hand off
  • block 1:
    • pathfind to marker frame 2
    • face target
    • run a 3-iteration fire loop while line-of-fire is clear
    • return to marker frame 1
    • face target
    • sleep 120
    • restart block 1

Human read:

  • waypoint-based peek-and-return behavior across two authored combat markers

6. ThreeToFour

Role:

  • same pattern as OneToTwo, but between marker frames 3 and 4

Decoded behavior:

  • block 0: target object 1, face target, pathfind marker 3, hand off
  • block 1:
    • pathfind marker 4
    • face target
    • if shot is clear, run a 3-shot burst loop
    • return to marker 3
    • face target
    • sleep 120
    • restart

Human read:

  • authored two-point lateral or cover movement using a second pair of marker frames

7. EggHopper1

Role:

  • multi-marker patrol shooter

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • pathfind marker 0, attempt a 3-shot loop
    • pathfind marker 1, attempt a 3-shot loop
    • pathfind marker 2, attempt a 3-shot loop
    • pathfind marker 3, attempt a 3-shot loop
    • pathfind home
    • face target
    • restart

Human read:

  • sweep across four authored combat markers in order, trying a short firing burst at each stop, then return home

8. StepOutShootNE

Role:

  • east-west step-out gunner, nominally biased to an east-facing start

Decoded behavior:

  • block 0: target object 1, face east, hand off
  • block 1:
    • walk outward
    • face target
    • up to 3 short fire attempts with 5-tick delays
    • face west
    • walk back
    • face target
    • another 3 short fire attempts with 5-tick delays
    • if blocked, wait 25 ticks instead of firing through
    • face east
    • restart

Human read:

  • pop out from one side, take a short burst, step back across the lane, burst again, and reset orientation

9. StepOutShootNW

Role:

  • mirror-image of StepOutShootNE, nominally biased to a west-facing start

Decoded behavior:

  • block 0: target object 1, face west, hand off
  • block 1:
    • walk outward from the west-facing side
    • face target and attempt short burst fire
    • if blocked, delay 25
    • face east and walk across
    • repeat the short-burst pattern
    • face west and restart

Human read:

  • same authored cover-pop logic as the northeast variant, but with opposite home orientation

10. Random_CHAOS

Role:

  • immediate short burst, then aggressive midpoint pressure

Decoded behavior:

  • block 0:
    • target object 1
    • face target
    • run a 2-shot loop with 30-tick sleeps
    • hand off
  • block 1:
    • face target
    • pathfind midpoint
    • face target
    • run a 3-shot loop with 30-tick sleeps
    • restart block 1 forever

Human read:

  • start with a short static burst, then keep pressing toward midpoint and firing

11. Static_Chaos

Role:

  • pure stationary burst shooter

Decoded behavior:

  • block 0: target object 1, face target, hand off
  • block 1:
    • face target
    • loop 3 times: fire, loop
    • sleep 30
    • restart

Human read:

  • no pathfinding at all; just keep facing and firing from the current spot

12. Stand_Choas

Role:

  • stationary turret-like spread pattern

Decoded behavior:

  • block 0: target object 1, hand off
  • block 1:
    • face target
    • if shot is clear, fire once and skip to the post-shot delay
    • otherwise rotate and fire in a fixed sweep: left, original/right-adjusted, then right again
    • sleep 30
    • face target
    • restart

Human read:

  • a stand-and-sweep pattern for targets that are partially obstructed or moving across the arc

13. 123_Shoot

Role:

  • two marker hops followed by midpoint pressure

Decoded behavior:

  • block 0:
    • target object 1
    • face target
    • pathfind marker 0
    • face target
    • pathfind marker 1
    • face target
    • run a 2-shot loop
    • hand off
  • block 1:
    • face target
    • pathfind midpoint
    • run a 3-shot loop
    • restart

Human read:

  • staged opening movement across authored markers, then transition into a more ordinary midpoint-pressure gunner

Pattern Summary

Across the whole file, the tactics cluster into a few clear families:

Family Tactics Shared idea
stationary shooters Pivot, Static_Chaos, Stand_Choas little or no movement; rely on facing and burst loops
midpoint pressers Dumb, Advance, Mental, Random_CHAOS, 123_Shoot block 1 move toward a midpoint lane when a shot is blocked
cautious/range-gated Careful only engage inside a distance window and avoid overcommitting
authored marker shuttles OneToTwo, ThreeToFour, EggHopper1, 123_Shoot block 0 follow placed map markers keyed by frame number
step-out cover shooters StepOutShootNE, StepOutShootNW walk out, burst, walk back, repeat

This is enough to treat COMBAT.DAT as a compact authored AI-script table rather than a loose name list.

Open Questions

  1. Tactic 0 is the main remaining semantic mismatch. The archive contains Dumb at index 0, while ScummVM still routes _tactic == 0 through genericAttack(). The live executable-side helper accepts tactic 0 as a normal COMBAT.DAT slot, so this needs a later direct compiled-side pass if exact retail precedence matters.
  2. The later two per-record offset slots are structurally present but operationally unimportant in this file. A future pass could still check whether any retail code path ever selects block 2 or 3.
  3. The marker-search opcode 0xA6 is strongly understood from ScummVM and the tactic names, but the live-game name of shape 0x33A and the exact authored map placement conventions remain better documented on the map-data side than in the live NE database.

Practical RE Use

For future compiled-side work, the main safe takeaways are:

  • tacticNo in NPC data is not cosmetic; it selects a real bytecode program.
  • block 0 is usually an initialization or reposition phase; block 1 is the stable loop.
  • the named tactics are portable data labels because the shipped file is identical across the local Remorse/Regret variants.
  • waypoint-driven tactics should be interpreted together with local marker placements, not only from executable code.