Crusader_Decomp/docs/map_1_spawners_targeted_investigation.md
2026-04-02 01:15:16 +02:00

12 KiB

Map 1 Targeted 0x04D0 Spawner Investigation

Scope

This note closes the specific map-1 question about suspicious 0x04D0 frame-paired monster spawners, especially the cases where nearby frame 0 and frame 1 records point at different DTABLE rows.

Primary evidence used in this pass:

  • decompressed cache scene: k:\ghidra\crusader_map_viewer\map_renderer\.cache\scene-cache\remorse\map-1\9ccaa5dabe08947e\scene.json
  • extracted pseudocode: MONSTER.slot_0F enterFastArea, MONSTER.slot_0A equip, ITEM.slot_2D
  • ScummVM Crusader sources: glob_egg.cpp, actor.cpp, item.cpp, remorse_intrinsics.h, convert_usecode_crusader.h
  • live Ghidra instruction windows around 10a0:3889, 10a0:0c40, and 10a0:3d3f

The exact coordinate examples mentioned at the start of the investigation do not appear in this cache hash, so the conclusions below are tied to the decompressed cache currently used by the renderer rather than to an older export or a different build.

Short Answer

  • Map 1 really does contain many authored frame-0 / frame-1 0x04D0 pairs.
  • Those pairs are not well explained as duplicate placements or viewer noise.
  • The nearby-pair match key is QLo = quality & 0xff, not npcNum.
  • Frame 0 is the only state checked by MONSTER.enterFastArea.
  • In the exact extracted MONSTER.enterFastArea body, the automatic lane is taken when mapNum bit 0x08 is clear, not set.
  • Frame 1 is skipped by MONSTER.enterFastArea and remains the strongest practical preview candidate in the currently confirmed auto-enabled map-1 and map-248 pairs.

Verified Usecode Chain

The strongest current chain is:

  1. MONSTER.slot_0F (enterFastArea) checks frame == 0 and conditionally routes into MONSTER.equip(...).
  2. MONSTER.slot_0A (equip) immediately returns unless the current item is still frame == 0.
  3. ITEM.slot_2D scans nearby shape 0x04D0 items, requires a partner with frame == 1, requires the same intrinsic_00EA(...) value on both items, and then calls intrinsic_012F(partner_frame1, source_frame0).
  4. intrinsic_012F is the retail create-NPC helper at 10a0:3d3f (Actor::I_createActorCru / Item_Intrinsic12F_CreateNPC).

That is direct evidence that the frame pairing is authored behavior, not just a visual/editor convention.

ScummVM plus the matching retail disassembly split the arguments further:

  • the second argument (source_frame0) supplies the DTABLE row through other->getNpcNum()
  • the first argument (partner_frame1) supplies facing from item->getNpcNum() & 0x0f
  • the frame-1 partner also supplies the difficulty gate from mapNum & 3, the weapon flag from mapNum & 4, the default-activity-1 byte from quality >> 8, and the low-quality unk byte
  • the frame-0 source supplies the default-activity-0 byte from quality >> 8 and default-activity-2 from mapNum

That open-source call shape is still useful, but it is no longer strong enough on its own to settle authored spawn identity for every paired 0x04D0 case.

Important Corrections

0x00EA is getQLo, not getNpcNum

An older local parser table still labeled 0x00EA as Item::getNpcNum(void), but stronger sources overrule it:

  • ScummVM intrinsic tables map 0x0EA to Item::I_getQLo
  • local intrinsic dumps map 10a0:3889 / Int0EA to Item::I_getQLo
  • live disassembly at 10a0:3889 masks to the low byte

So the pair-match test inside ITEM.slot_2D is a low-quality-byte comparison, not an NPC-row comparison.

mapNum bit 0x08 needed one more correction

The exact extracted MONSTER.slot_0F body still reads:

  • frame == 0 is required
  • if (!(a & 8)) spawn MONSTER.equip(...)
  • frame == 1 skips that hook

So the safe viewer interpretation stays:

  • clear bit 0x08 = automatic enter-area lane enabled
  • set bit 0x08 = automatic lane blocked / dormant until another path signals it

Representative Map-1 Cache Pairs

These are concrete decompressed-cache examples from the active hash.

Observer / RoamingSusan pair

  • item:186 at 50784, 5888, 0: frame 0, mapNum = 1, npcNum = 6, quality = 1315, preview Observer
  • item:187 at the same location: frame 1, mapNum = 10, npcNum = 11, quality = 1315, preview RoamingSusan

Both records share QLo = 35 even though their npcNum values differ.

Observer / ChemSuitGuy pair

  • item:635 at 37310, 23102, 0: frame 0, mapNum = 8, npcNum = 6, quality = 1792, preview Observer
  • item:636 at the same location: frame 1, mapNum = 7, npcNum = 2, quality = 512, preview ChemSuitGuy

Both records share QLo = 0.

Observer / Guard nearby pair

  • item:637 at 37830, 22978, 0: frame 1, npcNum = 4, quality = 2048, preview GUARD
  • item:638 at 37846, 22986, 0: frame 0, npcNum = 6, quality = 1280, preview Observer

These are not perfectly colocated, but they are close enough for the viewer's local-pair search and again share QLo = 0 rather than npcNum.

NPC 99 example

  • item:14406 near 59864, 35592, 96: frame 0, npcNum = 99

This remains a useful outlier because it shows that odd npcNum values can exist directly on 0x04D0 records. Under the current create-NPC model, a frame-0 npcNum = 99 would mean DTABLE row 99 is the spawn-identity source if that record reaches Actor.createActorCru.

Current Best Model

The safest synthesis from cache, usecode, ScummVM, and live retail disassembly is:

  • npcNum still matters because the create-NPC path is DTABLE-backed.
  • But the authored pairing between nearby frame-0 and frame-1 0x04D0 records is not keyed by npcNum.
  • The pairing key is the low quality byte.
  • Frame 0 still owns the verified automatic enterFastArea controller lane.
  • Frame 1 is still the required partner state in ITEM.slot_2D.
  • But confirmed auto-enabled pairs now fit better if the frame-1 preview is treated as the practical visible-NPC candidate while frame 0 stays the controller-side row.

That explains why map 1 can legitimately contain nearby pairs whose two records name different NPC rows. The pair is not necessarily saying "these are two copies of the same actor". It is more consistent with a split-role authored setup where one 0x04D0 record is the frame-0 controller and the other is the frame-1 paired preview/state row.

Practical Read Of The Map-1 Examples

Under the current model:

  • the auto-enabled Observer / RoamingSusan pair is better read as frame 0 controls the lane, but the practical visible-NPC candidate is RoamingSusan
  • blocked Observer / ChemSuitGuy and Observer / GUARD pairs still fit a dormant-controller interpretation, so they are not equivalent evidence for the visible actor
  • the viewer should stop claiming that frame 0 preview always equals the spawned NPC

So the surprising frame-1 labels are no longer best treated as mere noise or a purely control-side annotation. In the confirmed auto-enabled examples, they are the strongest current practical preview of what actually appears.

Map 248 Follow-Up

Map 248 adds a useful counterexample set because it still contains multiple 0x04D0 records previewing Observer.

Two especially useful stable-ID pairs from the decompressed cache are:

  • fixed:4161 at 54656, 56936, 0: frame 1, preview Observer, paired with fixed:4162 frame 0, npcNum = 100, mapNum = 0, same QLo = 63
  • fixed:3391 at 59846, 45194, 0: frame 1, preview Observer, paired with fixed:3390 frame 0, npcNum = 134, mapNum = 0, same QLo = 9

Those frame-0 rows matter because the generated Remorse DTABLE used by the renderer currently exposes only rows 0..34. npcNum = 100 and npcNum = 134 do not resolve to known Remorse DTABLE rows at all, while the paired frame-1 rows resolve cleanly to Observer.

There are also several blocked frame-0 Observer rows in map 248 such as:

  • fixed:2061 frame 0, preview Observer, mapNum = 8, paired with frame-1 ChemSuitGuy
  • fixed:2067 frame 0, preview Observer, mapNum = 8, paired with frame-1 GUARD

That split is important:

  • the blocked frame-0 Observer rows fit the dormant-controller interpretation cleanly
  • the frame-1 Observer rows paired with auto-enabled frame-0 unknown rows are stronger candidates for the real in-map Observer spawns the user reported

So map 248 does more than merely weaken a blanket claim that frame 0 preview always equals the spawned NPC. It gives a direct contradiction for the old viewer theory:

  • the auto-enabled controller row can carry an unresolved out-of-range npcNum
  • the paired frame-1 row can still resolve cleanly to the actually reported in-map NPC

Current safest read is narrower:

  • frame pairing is definitely real
  • QLo still looks like the local match key
  • blocked frame-0 rows can clearly be dormant helpers
  • auto-enabled confirmed pairs currently line up better with the frame-1 preview as the practical visible-NPC cue
  • but the exact which side supplies the final NPC row in all authored cases question still needs one more call-site-level close, because the open-source call signature and the practical map evidence are still not perfectly reconciled

Pair-Class Scan

After the initial targeted examples, a broader cache scan over the active Remorse map-1 and map-248 exports was used to test whether those examples were outliers or a repeatable authored pattern.

For nearest opposite-frame 0x04D0 candidates matched by shared QLo within the renderer's local pair distance:

  • map 1 produced 55 auto-enabled mismatched valid pairs, 96 auto-enabled unresolved-controller / resolved-pair cases, and 21 blocked mismatched valid pairs
  • map 248 produced 14 auto-enabled mismatched valid pairs, 78 auto-enabled unresolved-controller / resolved-pair cases, and 8 blocked mismatched valid pairs

That matters because it rules out the previous "just a handful of weird examples" escape hatch.

  • auto-enabled pairs with different resolved previews are common
  • auto-enabled pairs where frame 0 does not even resolve to a valid Remorse DTABLE preview but frame 1 does are also common
  • blocked mismatched pairs remain common too, but they fit the dormant-controller interpretation much better than the visible-NPC interpretation

So the current viewer behavior is now intentionally asymmetric:

  • auto-enabled paired 0x04D0 previews use a single blue carrier, favoring the practical frame-1 preview side when that side resolves cleanly
  • blocked paired 0x04D0 previews use a single red carrier on the dormant frame-0 controller side

That single-carrier rule is what removes the misleading double Observer / RoamingSusan overlays without pretending the underlying create path is fully closed.

Viewer Implications

This pass justifies two concrete renderer behaviors:

  • local pair arrows should match nearby opposite-frame 0x04D0 items by shared QLo
  • the spawner UI should treat clear mapNum bit 0x08 as auto-enter enabled and set bit 0x08 as dormant / blocked
  • paired 0x04D0 previews should render once per pair rather than once per record, using blue for the active carrier and red for dormant controller previews

It does not yet justify a stronger claim that every nearby same-QLo pair is guaranteed to be the runtime pair actually chosen in every scenario. The current overlay should stay local and conservative.

Remaining Uncertainty

  • The static Actor.createActorCru argument reading and the practical map evidence are not fully reconciled yet. Map 1 and map 248 now strongly suggest the frame-1 preview is the better viewer-facing NPC cue for confirmed auto-enabled pairs, but the final call-site argument ownership still needs a tighter close in retail disassembly or runtime tracing.
  • The user's original coordinates likely came from a different scene export, cache hash, or build, and that mismatch should be kept explicit instead of silently normalized away.
  • npcNum = 99 and other sparse/outlier rows still deserve a separate cleanup pass once more map-1 pairs are cataloged.