Crusader_Decomp/docs/map_renderer/npc-spawners.md

216 lines
16 KiB
Markdown
Raw Normal View History

2026-03-30 00:19:01 +02:00
# NPC Spawner Investigation
## Goal
Recover the meaning of the `npcNum` field on Crusader editor and egg-side helper objects, determine which shapes really use DTABLE-backed NPC records, and surface only the evidence-backed lookup data in the public map renderer.
## Short Answer
- Shape `0x04D0` editor objects are not backed by a hardcoded executable-only NPC name list.
- For `0x04D0`, the `npcNum` field indexes external NPC metadata stored in `STATIC/DTABLE.FLX` for No Remorse and `STATIC_REGRET/DTABLE.FLX` for No Regret.
- The relevant DTABLE layout matches ScummVM's Crusader `NPCDat` loader:
- object `0` = fixed `142`-byte NPC rows
- object `2` = parallel `32`-byte NUL-terminated names
- Current evidence does not justify applying that same DTABLE lookup to every shape that happens to carry an `npcNum` byte.
- The renderer now exposes the recovered DTABLE name and spawn shape only for the evidence-backed `0x04D0` path.
## Verified File Layout
ScummVM's Crusader engine already documents the DTABLE structure in `world/actors/npc_dat.cpp`:
- row size = `142` bytes
- name size = `32` bytes
- shape offset = `0x3e`
- min/max HP = `0x00/0x02`
- default weapon slots = `0x18/0x1a`
- default activities = `0x1e`, `0x40`, `0x42`
Its `filesys/flex_file.cpp` also confirms the Flex header shape used to read the archive index:
- entry count at file offset `0x54`
- object table at file offset `0x80`
Those offsets were used to extract the local retail DTABLE rows directly.
## Executable-Side Corroboration
The live `CRUSADER.EXE` session already had the core DTABLE helper names recovered:
- `1118:0075` = `DTable_Load`
- `1118:056a` = `DTable_GetNameForShapeNo`
- `10e8:0152` = `NPC_SetDataFromDTable`
- `10e8:336d` = `NPC_DTable_GetMaxHPForNPC`
That function cluster is consistent with `npcNum` being an index into external DTABLE-backed NPC records, not a local `0x04D0`-specific switch table.
## Shape Review
This pass specifically re-checked the shapes that came up during renderer work: `0x04D0`, `0x024F`, `0x04B1`, and `0x0011`.
### `0x04D0`
- The old Crusader disassembly corpus labels usecode class `0x04D0` as `MONSTER`.
- Existing local analysis in `docs/usecode-alarmhat-analysis.md` shows multiple scripts scanning nearby shape `0x04D0` objects and driving them through monster/helper behavior.
- In the current script evidence, those nearby scans explicitly require frame `0` on the found `0x04D0` object before calling the equip/activation helper.
- The executable-side DTABLE helper cluster and the extracted DTABLE rows line up cleanly with the renderer's `npcNum` observations for this shape.
Current conclusion: `0x04D0` is the confirmed DTABLE-backed NPC spawner/controller shape, so DTABLE name lookup is appropriate here. Frame `0` is the better-supported actionable/helper state. Frame `1` still appears as a paired variant in map data, but this pass does not yet pin down its exact semantics.
### `0x024F`
- In No Remorse, the current renderer and ScummVM family mapping both classify `0x024F` as a family `7` monster egg shape.
- Exported Remorse scene data shows frame `0` `0x024F` eggs with non-zero `npcNum` values and family `7` egg metadata. Example cases include Remorse map 69 with `npcNum = 2` and `npcNum = 6`, and Remorse map 2 with `rawNpcNum = 5` on another `0x024F` monster egg.
- The preview path now treats those frame `0` monster eggs as a second evidence-backed DTABLE consumer. After rebuilding cache, Remorse map 69 produces a concrete preview case where a `0x024F` frame `0` egg with `npcNum = 6` resolves to `Observer` and emits `npcPreview.shapeDefId = shape:828`.
- Frame `1` `0x024F` records also appear in No Remorse, but the concrete checked cases were zeroed placeholders with `mapNum = 0`, `npcNum = 0`, `quality = 0`, and no preview metadata. The current renderer therefore only enables the preview for frame `0` monster eggs with a non-zero `npcNum`.
- In current exported No Regret data, `0x024F` is not only visually categorized differently. The exported shape definition itself resolves to `family: 0`, `kind: terrain`, unlike the No Remorse definition which resolves to `family: 7`, `kind: egg`. That means the present Regret difference is coming from the underlying typeflag/family data, not from a UI-only catalog mismatch.
Current conclusion: `0x024F` frame `0` monster eggs are not the same authoring form as `0x04D0`, but in Remorse they do carry usable DTABLE-style `npcNum` rows. That is strong enough to preview the spawned NPC for those eggs, while keeping the broader egg-family interpretation separate from the `0x04D0` editor/controller path.
### `0x04D0` Field Ambiguity Notes
- The strongest current activation control lives in `MONSTER.slot_0F enterFastArea`, not in DTABLE. That script only checks `0x04D0` objects when `frame == 0`, then blocks the automatic enter-area lane if `mapNum & 0x08` is set.
- Current safest read: `frame 0` is the only state that participates in the automatic `enterFastArea` spawn path, while `frame 1` skips that hook and is therefore more likely to be used in paired or externally signaled setups.
- `mapNum` is not consistently populated across `0x04D0` objects. Some records carry a non-zero map value and others leave it at `0`, so the field is probably contextual control data rather than a universal NPC selector.
- Within that contextual control data, bit `0x08` is now evidence-backed as an `auto-enter disabled` flag for the `MONSTER.enterFastArea` lane.
- `quality` is also not specific to the DTABLE row. For example, `quality = 1285` shows up on unrelated non-`0x04D0` shapes in the exported scenes, so that value should not be read as proof of a particular NPC identity.
- `quality` low byte still does not look like the primary `spawn immediately vs wait` control. The current exported scripts do not use it in `MONSTER.enterFastArea`, although Regret `ALARMHAT` does compare nearby `0x04D0` `Item.getQLo(...)` values against difficulty lanes `0/1/2` before equipping those helpers.
- DTABLE row `0` is named `Crusader`, but the open-source engine does not use DTABLE row `0` to bootstrap the player. ScummVM's `CruGame::startGame()` takes the main actor stats from `getNPCDataForShape(1)`, while the generic Crusader actor-creation path accepts `npcNum = 0` as an ordinary DTABLE index.
- Exported scene data shows a repeatable authored pattern where a `0x04D0` frame `0` record with `npcNum = 0` is colocated with a frame `1` record carrying the non-zero NPC row. Example pairs include exported Remorse map 246 and map 9, where the frame `0` record stays at `npcNum = 0` while the frame `1` partner carries rows `8` and `2` respectively.
- Current working model: `npcNum = 0` on `0x04D0` is a real authored state, but not strong evidence that the object literally means "spawn the player". It is safer to present it as DTABLE row `0` with a caution note than to relabel it as a special player-only sentinel.
### `0x04B1`
- `0x04B1` is catalogued as an editor object in both games.
- In the recovered usecode exports, `TRIGGER.slot_20` iterates nearby `shape=0x04B1` items, compares `Item.getQLo(item)` against a base-link id, then branches on `Item.getMapNum(item)` flag bits before dispatching more trigger logic.
- That makes `0x04B1` look like a trigger/link controller object rather than a plain NPC-row selector.
Current conclusion: `0x04B1` does use the standard item fields, including `mapNum` and low-quality link data, but this pass did not find evidence that its `npcNum` byte should be decoded through DTABLE/NPCDat. The renderer should keep showing the raw fields, not an inferred NPC name.
### `0x0011`
- `0x0011` is catalogued as an egg-side editor trigger shape in the public renderer catalogs.
- The usecode export corpus references `0x0011` in several trigger-oriented constant sets, but this pass did not recover a clean DTABLE/NPCDat correlation for that shape.
- The current evidence is therefore weaker than for `0x04D0` and does not support treating `0x0011` as just another DTABLE NPC spawner.
Current conclusion: `0x0011` should continue to display its raw `npcNum` field for inspection, but not receive automatic DTABLE name resolution unless later RE work proves the mapping.
## Recovered No Remorse Examples
Direct extraction from `STATIC/DTABLE.FLX` produced these representative rows:
| npcNum | Name | Shape |
| --- | --- | --- |
| 4 | `GUARD` | `0x02fd` |
| 6 | `Observer` | `0x033c` |
| 10 | `Thermatron` | `0x0338` |
| 15 | `EliteStormTrooper` | `0x04d1` |
| 19 | `Vetron` | `0x04e6` |
| 33 | `FemaleOfficeWorker` | `0x0597` |
The original user hunch was effectively correct: No Remorse entry `6` is an otherwise obscure NPC that uses shape `0x033c`. The recovered DTABLE name for that row is `Observer`.
## Recovered No Regret Differences
No Regret keeps the same storage scheme but changes several rows:
| npcNum | Name | Shape |
| --- | --- | --- |
| 4 | `LMC Guard` | `0x0308` |
| 6 | `HQ Guards` | `0x057a` |
| 14 | `LMC Roll Out` | `0x05f0` |
| 30 | `Colonel Shepherd` | `0x0463` |
| 35 | `RoboDraygon` | `0x05b1` |
| 38 | `Cryotron` | `0x05e2` |
Because the public renderer supports both games, the UI lookup is keyed by game id rather than assuming a single shared NPC table.
## Relationship To Monster Eggs
These `0x04D0` NPC spawners are related to monster eggs only in the broad sense that both can result in actor creation.
They are not the same data path.
- The existing egg investigation showed that Crusader monster eggs use the egg-family path where the monster id is derived from `mapNum >> 3`.
- The `0x04D0` spawner path instead uses the item's `npcNum` byte as a DTABLE/NPCDat record index.
- Remorse `0x024F` frame `0` monster eggs appear to bridge those two worlds: they are still family `7` eggs, but exported scene data shows that they can also carry a non-zero `npcNum` that matches a useful DTABLE row for preview and inspection.
- That means a renderer should not label `0x04D0` editor objects as monster eggs or try to decode their `npcNum` through egg-family rules.
- `0x0011` remains in the broader egg/editor lane, but this pass did not find evidence that its `npcNum` field reuses the `0x04D0` DTABLE path.
Current safest model:
- monster eggs = egg-family object with monster id packed through the egg fields
- `0x024F` frame `0` monster eggs in Remorse = family `7` eggs that also expose a useful DTABLE-backed `npcNum` for preview
- `0x04D0` NPC spawners = editor objects pointing into DTABLE/NPCDat rows
Current practical difference between the two monster-spawn styles:
- `0x04D0` is the more explicit editor/controller spawner path. It carries larger visible editor-object state, shows paired frame behavior, and matches the recovered DTABLE helper cluster directly.
- `0x024F` is the egg-family monster spawn path. In Remorse frame `0`, it still exposes enough `npcNum` information to recover the spawned NPC for renderer preview, but it remains an egg-trigger object rather than the broader `0x04D0` controller form.
## Renderer Change
The tooltip now resolves recovered DTABLE metadata by game id and shows, for confirmed DTABLE-backed shapes:
- raw `npcNum`
- recovered DTABLE name when known
- DTABLE spawn shape when known
- a short frame note for `0x04D0` items so frame `0` vs `1` stays visible during inspection
- a decoded activation summary for `0x04D0` showing the current best read of `frame` plus `mapNum & 0x08`
For editable fixed-record `0x04D0` items, the inspector now also exposes two evidence-backed export controls:
- `Spawner frame`: switches between the `frame 0` enter-area checked state and the `frame 1` skip-enter-area state
- `Enter-area lane`: clears or sets `mapNum` bit `0x08`, which is the verified automatic-spawn suppression bit used by `MONSTER.enterFastArea`
The side panel now also exposes a dedicated `Monster Spawners` audit list for `0x04D0` records, including a filter for the `auto-enter blocked` subset. Clicking an entry centers and pins that spawner so its raw fields and editable controls can be audited quickly.
The viewport's `Show verified link arrows` overlay now draws two evidence-backed link families:
- teleport eggs point from teleporter eggs to teleport destinations that share the same teleport ID
- focused `0x04D0` items draw dashed arrows to nearby opposite-frame `0x04D0` candidates that share the same low-quality link key, reflecting the current paired-spawner hypothesis without claiming a stronger global linkage than the usecode proves
Current script-side corroboration for external `0x04D0` signaling is broader than `ALARMHAT` alone. The extracted public pseudocode also shows `ITEM.slot_2D`, `FUSPAC.slot_01 use`, and `MISS8.slot_20` scanning nearby `0x04D0` objects and keying off frame plus `Item.getQLo(...)`, which is why the renderer treats the low-quality byte as a local signal key rather than a guaranteed direct object pointer.
The lookup is no longer intended to be hand-maintained. `src/generate-npc-spawner-data.js` now extracts both games' rows from `STATIC/DTABLE.FLX` and `STATIC_REGRET/DTABLE.FLX` and writes the generated JSON file into the renderer cache at `.cache/npc-spawner-data.generated.json`.
The frontend consumes that JSON through `src/public/npc-spawner-data.js`, so the runtime data file is plain generated content rather than JavaScript codegen.
When a valid DTABLE row is present, the viewport renders a semitransparent blue preview of the target NPC shape above every visible eligible spawner whenever editor objects are visible. Hovered or pinned spawners are still drawn slightly stronger, but preview ghosts are no longer hover-only. The current renderer enables this for:
- `0x04D0` DTABLE-backed editor spawners
- `0x024F` frame `0` Remorse monster eggs that carry a non-zero `npcNum`
The current renderer uses frame `0` for NPC previews by default, except for `Observer`, which is forced to frame `0x00F` because the earlier frames are blank/broken in the retail assets.
Representative exported scene pairs:
- Remorse map 246: frame `0` item `item:162` uses `npcNum = 0`, `mapNum = 8`, `quality = 256`, while colocated frame `1` item `item:163` uses `npcNum = 8` with the same quality.
- Remorse map 9: frame `0` item `item:338` uses `npcNum = 0`, `mapNum = 0`, `quality = 1829`, while colocated frame `1` item `item:339` uses `npcNum = 2`.
- The same frame-paired authoring pattern also appears in Regret exports, although the exact non-zero partner row differs by map.
Serving paths now match the renderer modes:
- dynamic mode serves the cached JSON through `/api/npc-spawner-data`
- static export copies the cached JSON to `site/data/npc-spawner-data.json` and points `site-config.json` at that exported file
Current regeneration paths:
- `npm run generate-npc-data`
- `npm run build-cache`
- `npm run export-static`
Examples:
- No Remorse `npcNum 6` now displays as `6 (Observer)` with NPC shape `0x033c`
- No Regret `npcNum 35` resolves to `RoboDraygon` with NPC shape `0x05b1`
## Remaining Uncertainty
- This note closes where the `0x04D0` NPC list lives and how to recover names, but it does not yet prove every gameplay path that can instantiate those DTABLE rows.
- `0x04B1` has strong trigger/link evidence but still needs a fuller write-up tying its item fields to specific map-editing behavior.
- `0x0011` is still only partially characterized here; it is clearly in the egg/editor lane, but its relationship to `npcNum` remains unresolved.
- Shape `0x04D0` still uses the `MONSTER` usecode class in the old disassembly corpus, so there is still room to document how that script/controller layer cooperates with the DTABLE-backed actor creation path.
## TODO
- Do a deeper pass on `0x04B1` and `0x0011` so the raw `npcNum` field can be documented more precisely without overfitting DTABLE assumptions.