17 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
0x04D0editor objects are not backed by a hardcoded executable-only NPC name list. - For
0x04D0, thenpcNumfield indexes external NPC metadata stored inSTATIC/DTABLE.FLXfor No Remorse andSTATIC_REGRET/DTABLE.FLXfor No Regret. - The relevant DTABLE layout matches ScummVM's Crusader
NPCDatloader:- object
0= fixed142-byte NPC rows - object
2= parallel32-byte NUL-terminated names
- object
- Current evidence does not justify applying that same DTABLE lookup to every shape that happens to carry an
npcNumbyte. - The renderer now exposes the recovered DTABLE name and spawn shape only for the evidence-backed
0x04D0path.
Verified File Layout
ScummVM's Crusader engine already documents the DTABLE structure in world/actors/npc_dat.cpp:
- row size =
142bytes - name size =
32bytes - 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_Load1118:056a=DTable_GetNameForShapeNo10e8:0152=NPC_SetDataFromDTable10e8: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
0x04D0asMONSTER. - Existing local analysis in
docs/usecode-alarmhat-analysis.mdshows multiple scripts scanning nearby shape0x04D0objects and driving them through monster/helper behavior. - In the current script evidence, those nearby scans explicitly require frame
0on the found0x04D0object before calling the equip/activation helper. - The executable-side DTABLE helper cluster and the extracted DTABLE rows line up cleanly with the renderer's
npcNumobservations 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
0x024Fas a family7monster egg shape. - Exported Remorse scene data shows frame
00x024Feggs with non-zeronpcNumvalues and family7egg metadata. Example cases include Remorse map 69 withnpcNum = 2andnpcNum = 6, and Remorse map 2 withrawNpcNum = 5on another0x024Fmonster egg. - The preview path now treats those frame
0monster eggs as a second evidence-backed DTABLE consumer. After rebuilding cache, Remorse map 69 produces a concrete preview case where a0x024Fframe0egg withnpcNum = 6resolves toObserverand emitsnpcPreview.shapeDefId = shape:828. - Frame
10x024Frecords also appear in No Remorse, but the concrete checked cases were zeroed placeholders withmapNum = 0,npcNum = 0,quality = 0, and no preview metadata. The current renderer therefore only enables the preview for frame0monster eggs with a non-zeronpcNum. - In current exported No Regret data,
0x024Fis not only visually categorized differently. The exported shape definition itself resolves tofamily: 0,kind: terrain, unlike the No Remorse definition which resolves tofamily: 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 checks0x04D0objects whenframe == 0, then reaches the automatic enter-area lane when(mapNum & 0x08) == 0. - Current safest read:
frame 0is the only state that participates in the automaticenterFastAreaspawn path, whileframe 1skips that hook and is therefore more likely to be used in paired or externally signaled setups. - Confirmed map-1 and map-248 pairs now also show that
frame 0 controllershould not be collapsed intovisible spawned NPC. In the strongest checked auto-enabled cases, the paired frame-1 record is the better practical preview of the NPC that actually appears. mapNumis not consistently populated across0x04D0objects. Some records carry a non-zero map value and others leave it at0, so the field is probably contextual control data rather than a universal NPC selector.- Within that contextual control data, bit
0x08is now evidence-backed as anauto-enter blockedflag for theMONSTER.enterFastArealane. qualityis also not specific to the DTABLE row. For example,quality = 1285shows up on unrelated non-0x04D0shapes in the exported scenes, so that value should not be read as proof of a particular NPC identity.qualitylow byte still does not look like the primaryspawn immediately vs waitcontrol. The current exported scripts do not use it inMONSTER.enterFastArea, although RegretALARMHATdoes compare nearby0x04D0Item.getQLo(...)values against difficulty lanes0/1/2before equipping those helpers.- DTABLE row
0is namedCrusader, but the open-source engine does not use DTABLE row0to bootstrap the player. ScummVM'sCruGame::startGame()takes the main actor stats fromgetNPCDataForShape(1), while the generic Crusader actor-creation path acceptsnpcNum = 0as an ordinary DTABLE index. - Exported scene data shows a repeatable authored pattern where a
0x04D0frame0record withnpcNum = 0is colocated with a frame1record carrying the non-zero NPC row. Example pairs include exported Remorse map 246 and map 9, where the frame0record stays atnpcNum = 0while the frame1partner carries rows8and2respectively. - Current working model:
npcNum = 0on0x04D0is a real authored state, but not strong evidence that the object literally means "spawn the player". It is safer to present it as DTABLE row0with a caution note than to relabel it as a special player-only sentinel.
0x04B1
0x04B1is catalogued as an editor object in both games.- In the recovered usecode exports,
TRIGGER.slot_20iterates nearbyshape=0x04B1items, comparesItem.getQLo(item)against a base-link id, then branches onItem.getMapNum(item)flag bits before dispatching more trigger logic. - That makes
0x04B1look 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
0x0011is catalogued as an egg-side editor trigger shape in the public renderer catalogs.- The usecode export corpus references
0x0011in 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
0x04D0and does not support treating0x0011as 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
0x04D0spawner path instead uses the item'snpcNumbyte as a DTABLE/NPCDat record index. - Remorse
0x024Fframe0monster eggs appear to bridge those two worlds: they are still family7eggs, but exported scene data shows that they can also carry a non-zeronpcNumthat matches a useful DTABLE row for preview and inspection. - That means a renderer should not label
0x04D0editor objects as monster eggs or try to decode theirnpcNumthrough egg-family rules. 0x0011remains in the broader egg/editor lane, but this pass did not find evidence that itsnpcNumfield reuses the0x04D0DTABLE path.
Current safest model:
- monster eggs = egg-family object with monster id packed through the egg fields
0x024Fframe0monster eggs in Remorse = family7eggs that also expose a useful DTABLE-backednpcNumfor preview0x04D0NPC spawners = editor objects pointing into DTABLE/NPCDat rows
Current practical difference between the two monster-spawn styles:
0x04D0is 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.0x024Fis the egg-family monster spawn path. In Remorse frame0, it still exposes enoughnpcNuminformation to recover the spawned NPC for renderer preview, but it remains an egg-trigger object rather than the broader0x04D0controller 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
0x04D0items so frame0vs1stays visible during inspection - a decoded activation summary for
0x04D0showing the current best read offrameplusmapNum & 0x08
For editable fixed-record 0x04D0 items, the inspector now also exposes two evidence-backed export controls:
Spawner frame: switches between theframe 0enter-area checked state and theframe 1skip-enter-area stateEnter-area lane: clears or setsmapNumbit0x08, which is the verified automatic-spawn suppression bit used byMONSTER.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.
Recent viewer follow-up tightened that audit lane further:
- fixed-record
0x04D0items now surface their stablefixed:<mapSourceIndex>ids prominently and provide copy buttons - the tooltip keeps the explicit
☑ auto-enabledversus☒ dormantstate label, while the scene preview itself now carries the color signal instead of a separate on-map checkbox badge - tooltip and list wording now separate the verified frame-0 control lane from the current practical frame-1 preview heuristic instead of asserting that frame 0 always supplies the final visible NPC
- paired
0x04D0previews now use a single carrier per pair instead of drawing both records: blue for the currently active preview carrier, red for dormant controller previews
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
0x04D0items draw dashed arrows to nearby opposite-frame0x04D0candidates 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:
0x04D0DTABLE-backed editor spawners0x024Fframe0Remorse monster eggs that carry a non-zeronpcNum
The current renderer still uses each resolved row's known preview frame, except for Observer, which is forced to frame 0x00F because the earlier frames are blank/broken in the retail assets. For paired 0x04D0 rows, the UI now treats frame-1 previews as the stronger practical cue in confirmed auto-enabled examples, even though the frame-0 control path remains the verified automatic trigger lane.
The stronger reason for that rule is no longer just a few hand-picked examples. A broader cache scan across the active Remorse map-1 and map-248 exports found many auto-enabled mismatched valid pairs plus many auto-enabled cases where the frame-0 controller row does not resolve to a valid Remorse DTABLE preview at all while the paired frame-1 row does. That is why the renderer now suppresses duplicate pair ghosts instead of tinting both sides.
Representative exported scene pairs:
- Remorse map 246: frame
0itemitem:162usesnpcNum = 0,mapNum = 8,quality = 256, while colocated frame1itemitem:163usesnpcNum = 8with the same quality. - Remorse map 9: frame
0itemitem:338usesnpcNum = 0,mapNum = 0,quality = 1829, while colocated frame1itemitem:339usesnpcNum = 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.jsonand pointssite-config.jsonat that exported file
Current regeneration paths:
npm run generate-npc-datanpm run build-cachenpm run export-static
Examples:
- No Remorse
npcNum 6now displays as6 (Observer)with NPC shape0x033c - No Regret
npcNum 35resolves toRoboDraygonwith NPC shape0x05b1
Remaining Uncertainty
- This note closes where the
0x04D0NPC list lives and how to recover names, but it does not yet prove every gameplay path that can instantiate those DTABLE rows. 0x04B1now has a stable map-viewer USECODE landing point (CMD_LINK -> TRIGGER.slot_20), but it still needs a fuller write-up tying its item fields to specific map-editing behavior.0x0011is still only partially characterized here; it is clearly in the egg/editor lane, but its relationship tonpcNumremains unresolved.- Shape
0x04D0still uses theMONSTERusecode 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
0x04B1and0x0011so the rawnpcNumfield and theCMD_LINKlinkage fields can be documented more precisely without overfitting DTABLE assumptions.