Crusader_Decomp/docs/map_renderer/npc-spawners.md
2026-03-30 00:19:01 +02:00

16 KiB

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.