Crusader_Decomp/docs/map_1_spawners_targeted_investigation.md

194 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-04-02 01:15:16 +02:00
# 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.