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, andSTATIC_REGRET_DEMO/COMBAT.DATare byte-identical.- All six files are
1734bytes and share SHA-256c6097a721141a2e66ec6d0cd578427305f28ae9efe8a206bd5cf946ce2075faf. - ScummVM's Crusader support code and the live
CRUSADER.EXEdatabase 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.EXEfunction/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
14populated tactic records. - Each tactic record has a zero-padded
16-byte name, followed by fouruint16block offsets, followed by bytecode. - In the shipped data, only block
0and block1matter. Block0acts like an entry/setup phase. Block1acts 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
0xA6as a search for nearby shape0x33Amarker objects by frame number; that matches the naming ofOneToTwo,ThreeToFour,EggHopper1, and123_Shoot. - There is one notable model discrepancy to keep in mind: the shipped archive contains a valid entry
0namedDumb, but ScummVM still special-cases tactic number0as a generic built-in attack path rather than always interpretingCOMBAT.DATentry0. 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
8bytes each:<uint32 offset, uint32 length> - the shipped file leaves most directory slots empty and populates only
14entries
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_ChoasRandom_CHAOS/Static_Chaosmixed 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
0x002cand block 1 at0x0031. - The waypoint tactics
OneToTwo,ThreeToFour, and123_Shoothave a longer setup block and therefore move block 1 farther forward. - The later two offset slots are usually
0x004dand0x004eeven 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:
combatDatTacticPtrat attack-process offset0x45combatDatTacticPtr2at0x49combatDatTacticCurOffsetat0x4dcombatDatBlockNoat0x4ftacticNoat0x51
Relevant live helpers:
1108:0586Attack_SetupForTacticNo1108:0506Attack_SetupForBlockNo10e8:3572NPC_GetNPCTacticNo10e8:358cNPC_SetNPCTacticNo
Current best read of the runtime flow:
- NPC state stores a tactic number in
ItemNPCData.field21_0x5c. Attack_SetupForTacticNovalidates 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.Attack_SetupForBlockNoselects one of the record's four offset words and seedscombatDatTacticCurOffsetfrom it.- 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>= 33000as an index into a10-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 block1. - In block
1, it behaves likerestart 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
3times: face target, fire, sleep30 - restart block 1
- if a shot is already clear, jump straight into a
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
- if line-of-fire is clear, enter a
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
481and 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
- if target distance is not under
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
1and marker frame2
Decoded behavior:
- block 0:
- target object
1 - pathfind to marker frame
1 - face target
- hand off
- target object
- 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
- pathfind to marker frame
Human read:
- waypoint-based peek-and-return behavior across two authored combat markers
6. ThreeToFour
Role:
- same pattern as
OneToTwo, but between marker frames3and4
Decoded behavior:
- block 0: target object
1, face target, pathfind marker3, 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
- pathfind marker
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 a3-shot loop - pathfind marker
1, attempt a3-shot loop - pathfind marker
2, attempt a3-shot loop - pathfind marker
3, attempt a3-shot loop - pathfind home
- face target
- restart
- pathfind marker
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
3short fire attempts with5-tick delays - face west
- walk back
- face target
- another
3short fire attempts with5-tick delays - if blocked, wait
25ticks 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 with30-tick sleeps - hand off
- target object
- block 1:
- face target
- pathfind midpoint
- face target
- run a
3-shot loop with30-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
3times: 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
- target object
- 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
- Tactic
0is the main remaining semantic mismatch. The archive containsDumbat index0, while ScummVM still routes_tactic == 0throughgenericAttack(). The live executable-side helper accepts tactic0as a normal COMBAT.DAT slot, so this needs a later direct compiled-side pass if exact retail precedence matters. - 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
2or3. - The marker-search opcode
0xA6is strongly understood from ScummVM and the tactic names, but the live-game name of shape0x33Aand 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:
tacticNoin NPC data is not cosmetic; it selects a real bytecode program.- block
0is usually an initialization or reposition phase; block1is 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.