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

484 lines
No EOL
16 KiB
Markdown

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