Crusader_Decomp/docs/map_renderer/egg-identification.md
2026-04-02 01:15:16 +02:00

45 KiB

Egg Identification Investigation

Goal

Add a reliable egg browser to the map renderer and clarify what the game means by an egg "ID".

Short Answer

  • Fresh-game startup is hardcoded to map 1, egg 0x1e.
  • That startup egg is not read from CRUSADER.CFG or another external mission map.
  • For Crusader teleport eggs, the destination number that gameplay matches against is the low byte of the item quality field.
  • The broader egg families use different payload fields depending on egg type, so the renderer should not assume that every egg-family item uses the same number source.

Fresh-Game Default Start Egg

The existing reverse-engineering note in docs/first-mission-map-selection.md already establishes the startup path:

  • normal new game calls Teleporter_CreateProcessDirect(1, 0x1e, 1)
  • the controlling call site is in Game_Start
  • this is a code-selected default, not a config-file mapping

That means the known first-map spawn uses teleport egg id 0x1e on map 1.

ScummVM Crusader Egg Model

The ScummVM Crusader engine in engines/ultima/ultima8 maps Crusader egg families as follows:

  • family 3 = SF_GLOBEGG
  • family 4 = SF_UNKEGG (usecode trigger egg)
  • family 7 = SF_MONSTEREGG
  • family 8 = SF_TELEPORTEGG

Relevant files:

One structural detail matters here: generic trigger eggs and teleport eggs inherit from Egg, but MonsterEgg is its own Item subclass instead of an Egg subclass. That helps explain why Crusader monster eggs do not line up perfectly with the generic egg-field conventions.

Which Field Holds The Number?

There is not one universal answer for every egg-family item.

Teleport eggs

Teleport eggs are the important case for spawn/start locations.

ScummVM uses:

  • TeleportEgg::getTeleportId() = (_quality & 0xFF)
  • MainActor::teleport(mapNum, teleport_id) to move to a destination egg
  • CurrentMap::findDestination(id) to find a teleport egg target with matching low-byte quality

It also distinguishes two teleport egg roles:

  • frame != 1 = active teleporter trigger
  • frame == 1 = destination marker

So for renderer purposes, the gameplay-relevant teleport egg id is the low byte of quality, not mapNum.

Generic/usecode eggs

ScummVM's generic egg intrinsics expose:

  • Egg::I_getEggId() -> getMapNum()

So family 4 eggs use mapNum as their generic egg id in the engine interface.

Current Crusader viewer work now closes one additional family-4 detail for the 0x0011 placements used as usecode-trigger eggs:

  • quality & 0xFF is the subtype selector for the authored family-4 usecode class.
  • The runtime resolves that class as 0x0900 + QLo.
  • The currently verified authored subtype sets are:
    • Remorse: QLo 0, 1, 2, 4, 13 -> TRIGEGG, ONCEEGG, FLOOR1, CHANGER, MISS1EGG
    • Regret: QLo 0, 1, 2, 5, 8, 10, 13, 24 -> TRIGEGG, ONCEEGG, FLOOR1, MHATCHER, CHANGER, DOOREGG, MISS1, VIDEOEGG
  • npcNum does not behave like a DTABLE row here.
  • xRange = (npcNum >> 4) & 0x0f
  • yRange = npcNum & 0x0f
  • Crusader multiplies those range nibbles by 64 world units.
  • The runtime trigger check also uses a +/-48 Z window.

That is why the renderer now treats 0x0011 as a proximity/usecode-trigger egg with a projected footprint overlay, a subtype-aware USECODE landing point, and only the narrower local-arrow rules that are actually justified by the recovered subtype body.

One checked Remorse example now makes the CHANGER subtype concrete.

  • Map 13 fixed:4770 is item:12473:fixed:17:0:47888:53256:96 with mapNum = 37, quality = 4, and npcNum = 64.
  • quality & 0xff = 4 resolves the usecode class to 0x0904, which matches the extracted Remorse CHANGER::hatch body.
  • CHANGER::hatch reads eggNum = Egg.getEggId(arg_06) from mapNum, walks nearby roof candidates, compares each candidate's low quality byte against that egg id, and destroys the matching roof when Item.getQLo(roof) == eggNum.
  • The same local decoded scene contains nearby roof placements (shape:538, kind roof) whose quality & 0xff = 37, matching the egg id from mapNum.

Current safest read for CHANGER is therefore keyed roof-destruction trigger, not generic collision override logic and not a Regret-only family-4 subtype.

Monster eggs

ScummVM's monster egg accessor exposes:

  • MonsterEgg::I_getMonId() -> getMapNum() >> 3

That is a separate meaning from teleport ids.

There is an important Crusader-specific wrinkle for renderer work:

  • the generic ScummVM MonsterEgg model stores monster shape in quality & 0x7FF and activity in the low three bits of mapNum
  • but exported No Remorse 0x024F frame 0 monster eggs often have quality = 0 and still carry non-zero npcNum values that line up with useful DTABLE rows
  • concrete exported examples include Remorse map 69 (npcNum = 2 and npcNum = 6) and Remorse map 2 (rawNpcNum = 5)
  • No Remorse frame 1 0x024F entries also exist, but the checked cases were zeroed placeholders with no useful npcNum, mapNum, or quality payload
  • exported No Regret 0x024F currently diverges earlier: its shape definition resolves to family: 0, kind: terrain rather than family: 7, kind: egg, so the renderer should not assume that Remorse monster-egg semantics carry over unchanged

So the renderer keeps two interpretations side by side for family 7 eggs:

  • the egg-family label remains monster id = mapNum >> 3, matching the engine intrinsic surface
  • the NPC preview for evidence-backed 0x024F frame 0 eggs comes from npcNum, because that field matches observed spawn identity better than the zero-heavy quality field in current Remorse exports

Glob eggs

Glob eggs expand glob contents using the item quality value as the glob reference.

Renderer Decision

The new map-renderer egg UI treats all egg-family items as eggs, but labels each one with the field that matches its ScummVM family behavior:

  • family 8: teleport id = quality & 0xFF
  • family 4: egg id = mapNum
  • family 7: monster id = mapNum >> 3
  • family 3: glob id = quality

That keeps the viewer useful for teleport/start-point work without flattening all egg families into one misleading scheme.

For the NPC preview overlay, the renderer now makes one additional evidence-backed exception:

  • 0x024F frame 0 monster eggs with a non-zero npcNum get the same DTABLE-backed blue NPC preview as 0x04D0 spawners

That exception is intentionally narrower than the generic egg browser labels. It reflects the currently verified Remorse data, not a blanket claim that every family 7 egg in every game uses npcNum as its authoritative spawn row.

Current Cross-Game State Of 0x024F

  • No Remorse: exported shape definition resolves to family: 7, kind: egg, and scene items produce egg.type = monster-spawn
  • No Regret: exported shape definition currently resolves to family: 0, kind: terrain, and scene items do not produce egg metadata

That is the strongest current reason to keep the new monster-egg preview support scoped to the evidence-backed Remorse path instead of enabling it globally for both games.

Weapon Room 250

The current renderer catalog data already contains egg-oriented notes that include 250 in the known egg-id lists for Crusader egg shapes, which is consistent with the user-observed convention that egg 250 is the weapons room/test room marker.

Those catalog references are evidence for the workflow and UI, but the renderer should still treat them as conventions layered on top of the raw egg-family decoding above.

Non-Egg Teleporter Carriers

Not every authored teleporter in the exported maps is itself an egg-family item.

  • Both retail games use shape 0x01DB (TELEPORTER_LIGHTS) as a non-egg teleporter helper even though the exported shape definition is terrain-like rather than family 8 egg content.
  • Current renderer scene exports show both frame 1 and some frame 0 0x01DB placements carrying small low-byte quality values that line up with real teleport-destination eggs.
  • Verified No Remorse examples include map 13, where frame 1 0x01DB items carry quality & 0xFF = 14 and the map also contains teleport-destination egg 14, and map 3, where one frame 1 0x01DB carries low-byte 16 and the map contains teleport-destination egg 16.
  • Verified No Regret examples now include map 3 frame-1 matches (quality 4 -> destination egg 4, quality 2 -> destination egg 2, quality 9 -> destination egg 9) plus the remaining frame-0 telepad helpers (quality 27 -> destination egg 27, quality 28 -> destination egg 28), map 5 (quality 78 -> destination egg 78), map 10 (quality 39 -> destination egg 39, quality 49 -> destination egg 49), map 11 (quality 19/20/21 -> destination eggs 19/20/21), and map 15 (quality 22 -> destination egg 22).

That evidence is strong enough for link-arrow visualization, but it is intentionally narrower than the family 8 egg rule above:

  • the renderer now treats frame 0 and frame 1 0x01DB placements as teleporter link sources for arrow rendering when they carry an integer destination id in quality
  • the link key is the low byte of quality
  • the destination side still comes from real teleport-destination eggs
  • this does not yet claim that every 0x01DB placement or every non-egg teleporter helper in Crusader uses the same schema

TELEPAD Hardcoding

The current best executable-side explanation for 0x01DB behavior comes from the checked Crusader usecode disassembly corpus, not from the generic ScummVM egg model.

  • Usecode class 475 (01DB TELEPAD) is a real authored telepad class in crusader_disasm.txt.
  • TELEPAD::gotHit reads the pad's quality into local theQual with Item::I_getQuality.
  • quality == 0xFF is treated as inert and exits without a teleport.
  • quality in the low range 1..0x1d uses the actor's current map together with the quality value as the egg id, then spawns TELEPAD::ordinal20 to perform the move.

That means the simple viewer rule for 0x01DB is not arbitrary UI inference. It matches a real quality-driven telepad dispatch lane.

The same TELEPAD::gotHit body also contains explicit hardcoded remaps and special cases rather than one universal quality -> same-map egg rule:

  • 0x1e -> map 0x28, egg 0x1e
  • 0x1f -> map 0x45, egg 0x45
  • 0x20 -> map 0x04, egg 0x10
  • 0x21 -> map 0x03, egg 0x11
  • 0x23 -> map 0x29, egg 0x1e
  • 0x64 -> map 0x05, egg 0x64
  • 0x65 -> map 0x05, egg 0x65
  • 0x82 -> map 0x19, egg 0x82
  • 0x83 -> map 0x19, egg 0x83
  • 0x84 -> map 0x19, egg 0x82
  • 0x85 -> map 0x19, egg 0x83
  • 0xd7/0xda/0xdb/0xdc -> map 0x05 with matching egg ids

TELEPAD::ordinal20 then confirms the actual move mechanism:

  • the normal lane calls MainActor::I_teleportToEgg(...)
  • same-map and cross-map forms both appear
  • map 0x28 and map 0x29 have extra hardcoded presentation/cutscene handling around the jump process, including R01, B01, wec, 1d, and 14a movie names

So the current safest renderer claim is:

  • frame 0 and frame 1 0x01DB placements are useful cross-game teleporter-link sources when low-byte quality matches a real destination egg
  • but the full authored gameplay semantics of TELEPAD include a hardcoded quality switch with special map-specific behavior, not just generic destination-egg matching

Elevator Helpers

The checked usecode corpus also closes one separate same-map elevator lane used by visible elevator car objects rather than by egg-family source triggers.

  • Usecode class 542 (021E ELEVATOR) reads Item::I_getQLo and Item::I_getQHi from the elevator object itself.
  • For same-map moves, ELEVATOR::gotHit dispatches ELEVATOR::ordinal20 with destination egg ids derived from the low quality byte.
  • The verified same-map cases are QLo 1..0x0f -> egg 1..0x0f and the explicit special case QLo 0x10 -> egg 4.
  • QLo 0x11 and QLo 0x12 are cross-map special cases in the checked body, so the viewer does not currently draw same-map arrows for those values.
  • ELEVATOR::ordinal20 later confirms that the move itself is still a MainActor::I_teleportToEgg(...) hop using the caller-supplied egg id and map.

Scene evidence lines up with that lane in No Remorse map 1:

  • shape:542 quality 0x0101 sits near destination egg 2, which matches a source platform that teleports to egg 1.
  • shape:542 quality 0x0202 sits near destination egg 1, which matches the return platform that teleports to egg 2.
  • the same authored pattern repeats for the other nearby elevator pairs, including 0x0105 -> egg 5 from the far endpoint and the local return platform 0x0206 -> egg 6 beside destination egg 5.

So the public renderer now treats same-map shape:542 frame-0 placements as elevator-link sources when their checked QLo values map to a local destination egg id.

Current Elevator Gap

  • No Regret map 3 destination egg 102 does not sit on the same currently verified ELEVATOR (shape:542) or LIFT (shape:307) lanes.
  • Current scene exports for that map show no nearby shape:542 or shape:307 objects at all.
  • A nearby editor/helper record item:1056:fixed:1201:0:41758:33694:0 carries mapNum 102, but there is not yet enough executable-side evidence to promote that into a viewer arrow rule.
  • The map renderer therefore leaves egg 102 unresolved for now instead of hardcoding a speculative elevator source pattern.

Adjacent Editor Objects: 0x04E3 And 0x04B1

These two shapes came up during egg-browser work because they sit near teleporter/elevator/trap authoring patterns, but the current evidence says neither one should be folded into the egg model.

0x04E3 frame 0: SKILLBOX

The recovered Crusader usecode corpus has a direct class label here:

  • Usecode class 1251 (04E3 SKILLBOX)
  • the recovered handler is SKILLBOX::func0A(sint16)

That body is difficulty-sensitive rather than egg-like.

  • it reads Game::I_getDifficultyLevel()
  • it branches on the object's frame
  • for frame == 2, it reads Item::I_getQLo(item), temporarily adjusts that low-quality byte by difficulty, dispatches TRIGGER::ordinal20, then restores the original QLo
  • for the other frames, it chooses between trigger lane 0 and trigger lane 1 based on a difficulty threshold derived from frame + 2
  • when Item::I_getMapArray(item) is nonzero, the same lane choice is made but with 0x80 added to the dispatched trigger selector

That gives the current best read of the family:

  • frame 0 = difficulty gate that flips at difficulty level 2
  • frame 1 = difficulty gate that flips at difficulty level 3
  • frame 2 = special skill-routing form that rewrites QLo per difficulty before dispatch

For the specific target in this pass, 0x04E3 frame 0 is therefore best read as a hidden difficulty gate or skill-route selector, not as a teleporter egg, generic egg id carrier, or NPC spawner.

Scene-cache evidence supports that split.

  • across cached maps, 0x04E3 appears mostly as frame 2, then frame 1, with relatively few frame 0 records (64 in cached Remorse scenes and 25 in cached Regret scenes)
  • frame 2 payloads cluster strongly by QLo, which matches the recovered Item::I_getQLo(...) access
  • frame 0 payloads are much more heterogeneous, which fits a gate/controller role better than a simple numeric spawn id
  • many dominant frame 2 records reuse the same npcNum/mapNum pairs such as 208/134, while the low byte of quality still varies meaningfully; that makes the low byte look like the authored control value and the other bytes look more like secondary metadata

What helps in the editor:

  • label 0x04E3 as SKILLBOX instead of generic Editor Object
  • show quality split into QLo and QHi
  • for frame 0 and frame 1, show a small derived note describing the difficulty switch point
  • for frame 2, show the resolved difficulty lanes explicitly: diff1 -> QLo, diff2 -> QLo + 1, diff3+ -> QLo + 2
  • keep npcNum and mapNum visible, but treat them as raw helper metadata rather than as DTABLE or egg ids

0x04B1 still does not have as clean a recovered class label as SKILLBOX, but the current usecode evidence already points in one direction.

  • an earlier trigger pass found TRIGGER.slot_20 iterating nearby shape=0x04B1 items
  • that trigger lane compares Item.getQLo(item) against a base link id
  • it then branches on Item.getMapNum(item) flag bits before dispatching additional trigger logic

This pass also re-confirmed that 0x04B1 is grouped with controller/helper shapes rather than with actor payload objects.

  • recovered control code such as ELEVATOR::gotHit explicitly scans nearby authored helper shapes and includes 0x04B1 in that control-side whitelist
  • that keeps 0x04B1 in the same ecosystem as trigger/elevator helper markers rather than DTABLE-driven spawn objects

The currently recovered cmd text is not yet a stable class name. It appears as a local variable name in recovered usecode exports, which fits a command/controller interpretation, but that is still weaker evidence than the SKILLBOX label above.

Scene-cache evidence strengthens the controller model.

  • cached Remorse scenes overwhelmingly use 0x04B1 as frame 0
  • cached Regret scenes use 0x04B1 across a much wider frame spread (0 through 10 in the current cache)
  • for Remorse frame 0, the most common low-byte values are small channel-like numbers such as 1, 2, 3, 5, 6, 10, 20, 30, 31, 40, 50, and 60
  • the full quality word often changes while the low byte stays on the same small channel number, which is exactly what the earlier Item.getQLo(...) trigger evidence would predict
  • the frequently repeated npcNum/mapNum pairs like 208/134 do not line up with a useful NPC-row interpretation and should stay raw for now

Current best read:

  • 0x04B1 frame 0 is a command/link helper used by trigger-style controller logic
  • quality & 0xFF is the strongest current candidate for the authored link or command id
  • mapNum is not a destination map; its low bits are routing flags and its high bits contribute to the decoded target shape
  • npcNum is not a DTABLE row here; it is the low byte of the decoded target selector used by TRIGGER.slot_21

The recovered TRIGGER body now gives a more concrete field split for 0x04B1 itself:

  • QLo = current link id matched against the upstream trigger chain
  • QHi = subcommand selector in the low three bits, plus a small argument in the upper bits
  • mapNum & 0x03 = command mode
  • mapNum & 0x04 = route into the item-targeting TRIGGER lanes instead of the NPC-triggering side
  • mapNum & 0x08 = phase gate (set -> phase 0 / 0x80, clear -> phase 1 / 0x81)
  • mapNum & 0x10 = low-priority/deferred execution bucket
  • ((mapNum & 0xE0) * 8) + npcNum = decoded target search shape or sentinel target code

The executable-side read is now concrete enough to say more than "it probably triggers something": several QHi subcommands are visibly specific.

  • subcommand 0 is a helper-dispatch lane: it walks nearby 0x0476 helpers with the same QLo and forwards the upper-bit argument into FREE.slot_30 using the helper's packed payload fields
  • subcommand 1 is a direct target-mutation lane whose exact effect depends on mode: the recovered paths can write QHi, write QLo, call equip, set frame, or run a timed TRIGGER.slot_22 -> DOOR.slot_21 pulse on matched targets
  • subcommand 2 is no longer just a placeholder in the editor readout: in the direct item-targeting body it resolves to a frame-set lane using the upper-bit argument as the frame value
  • subcommands 4 and 5 are verified link rewrites, adding to or subtracting from the active QLo before the controller continues scanning
  • subcommand 6 is a create-and-drop lane: it again resolves payload through nearby 0x0476 helpers, creates the target item, copies Q, moves it to the helper coordinates, and unequips/drops it

The strongest new practical result is that some authored 0x04B1 records can now be resolved to concrete nearby target shapes instead of staying purely abstract.

  • Remorse map 10 contains 0x04B1 items with decoded target shape 0x04D0, and several of those have nearby same-QLo 0x04D0 helper matches.
  • The same map also contains 0x04B1 items resolving to decoded target shapes 0x0476 and 0x04E3, each with local same-QLo matches.
  • Example authored records include mapNum=134, npcNum=208 -> target shape 0x04D0, mapNum=135, npcNum=118 -> target shape 0x0476, and mapNum=134, npcNum=227 -> target shape 0x04E3.

That still does not mean every 0x04B1 record is fully solved. Some target codes still decode to shapes that need more map-side correlation, and the NPC-triggering versus item-targeting paths do not use every subcommand in exactly the same way. But the object is now clearly more than a generic relay: it is a compact local trigger program that encodes phase, priority, target domain, target shape, link rewrites, and several concrete operation lanes in the standard item fields.

What helps in the editor:

  • relabel 0x04B1 from generic Editor Object to something like Trigger Link Controller or Cmd Link
  • show QLo and QHi separately, with QLo emphasized
  • decode mapNum into phase lane, priority, targeting mode, and high-bit target-shape contribution instead of only showing it raw
  • decode npcNum as the low byte of the target selector
  • show the derived target shape or sentinel family code directly in the tooltip
  • list nearby same-QLo exact-shape candidates when the decoded target shape can be resolved in the current map
  • do not attach DTABLE/NPC preview logic to this family
  • keep a same-QLo highlight/filter action, because that remains the strongest local linkage field even after the target-shape decode

The practical egg-browser outcome is that both shapes are adjacent to egg workflows, but neither one should currently be decoded as an egg-family object. 0x04E3 looks like a difficulty/skill gate, and 0x04B1 looks like a trigger-link controller.

More Editor Helpers

The next helper batch follows the same pattern: these are controller-side authored objects that sit near eggs, doors, hazards, or alarm setups, but they should stay in their own helper bucket instead of being collapsed into one generic editor-object schema.

0x0361 frame 0: EVENT

This one now has a direct recovered class label too.

  • Usecode class 865 (0361 EVENT)
  • recovered body: EVENT::equip

EVENT::equip is a large event multiplexer rather than a single-purpose marker.

  • it immediately reads link = Item.getQLo(arg_06)
  • it branches on the incoming event number
  • several branches dispatch TRIGGER.slot_20
  • other branches touch camera state, audio, NPC actions, doors, and nearby NUMBERS helpers

Current best read:

  • 0x0361 is a generic scripted event controller/helper
  • QLo is the strongest authored linkage field currently visible in the recovered body
  • QHi is used by at least some counter-style branches, so it is worth surfacing too
  • frame 0 should be treated as one authored placement of that broader event family, not as a special egg/NPC record

What helps in the editor:

  • override placeholder-style catalog names with EVENT
  • emphasize QLo and QHi
  • describe it as a generic event controller, not a visible prop

0x0500 frame 0: STEAMBOX

This helper also has a direct class label.

  • Usecode class 1280 (0500 STEAMBOX)
  • recovered body: STEAMBOX::equip

The recovered handler has two visible controller lanes.

  • for event == 0, it scans nearby steam-family shapes and matches them by Item.getQLo(...)
  • for event == 1, it scans nearby shape=0x03A9 items, again matched by QLo
  • matching items then dispatch into STEAMBOX.slot_20 or STEAMBOX.slot_21

Current best read:

  • 0x0500 is a steam hazard/control relay, not a generic placeholder cube
  • QLo is the authored channel/link id
  • frame matters on nearby controlled shapes more than on the controller itself

What helps in the editor:

  • label it STEAMBOX
  • show QLo/QHi with QLo emphasized as the steam channel
  • keep the role text focused on nearby steam/hazard linkage

0x0561 frame 0: ALARMHAT

This family was already partially documented in the dedicated alarm note, and the extracted body matches that earlier read.

  • Usecode class 1377 (0561 ALARMHAT)
  • recovered body: ALARMHAT::equip

The visible structure is local alarm-state control.

  • frame 0 scans nearby shape=0x04D0 helpers and targets their frame 0 state
  • nonzero frames gate on Item.isOnScreen(arg_06) and nearby actor-family presence before scanning the same 0x04D0 family

Current best read:

  • 0x0561 is a local alarm-state driver
  • it is meant to flip or arm nearby 0x04D0 controller/spawner objects
  • frame 0 is the simple always-check local alarm form; nonzero frames add extra activation conditions

What helps in the editor:

  • label it ALARMHAT
  • add a frame note calling out frame 0 as the direct local alarm lane
  • keep npcNum/mapNum raw instead of forcing a DTABLE interpretation onto this helper itself

0x0581 frame 0: ALRMTRIG

This one is compact and comparatively clean.

  • Usecode class 1409 (0581 ALRMTRIG)
  • recovered body: ALRMTRIG::equip

The whole handler is an alert-state relay.

  • it checks Item.getMapArray(arg_06)
  • it checks World.getAlertActive()
  • it dispatches TRIGGER.slot_20 with lane 0, 1, 0x80, or 0x81

Current best read:

  • 0x0581 is an alert/alarm trigger relay
  • the important authored split is mapArray == 0 versus nonzero
  • the second split is world alert state inactive versus active

What helps in the editor:

  • label it ALRMTRIG
  • show the mapNum/mapArray byte in hex because it behaves like a flag lane selector here
  • describe the four resulting trigger lanes explicitly

0x04F8 frame 0: destroyable-door helper

This shape still lacks a clean No Remorse class label in the recovered tables, but the door-side behavior is already concrete enough to expose in the editor.

  • DOOR.slot_23 iterates nearby shape=0x04F8 items after the door damage path
  • it matches them by Item.getQLo(deathBox) == Item.getQLo(arg_06)
  • it dispatches TRIGGER.slot_20 on lane 0 when Item.getMapArray(deathBox) == 0
  • otherwise it dispatches the 0x80-offset lane

Current best read:

  • 0x04F8 is a door-side helper that lets authored doors become destroyable and then forward into trigger logic
  • QLo is the local door/link key
  • the map-array byte chooses the normal trigger lane versus the +0x80 variant

That is strong enough to support an editor-facing role hint even though the final class/name provenance is still incomplete.

shape_04e3 / 0123only

The workspace did not yield an exact 0123only string hit in the current source, catalogs, or extracted usecode artifacts.

Current safest read:

  • there is no evidence yet that 0123only is a separate helper family from 0x04E3
  • the only strong recovered identity for shape 0x04E3 is still SKILLBOX
  • treat 0123only as an external nickname or pending label until a real source hit turns up

So the editor integration should continue to show SKILLBOX for 0x04E3 and avoid promoting 0123only to a canonical name.

0x0120: FASTSKIL

This shape is a second skill-family controller, but it is not the same object as SKILLBOX.

  • Usecode class 288 (0120 FASTSKIL)
  • recovered handler: FASTSKIL::enterFastArea()

The recovered body is short enough to read directly.

  • it waits 5 ticks before doing anything
  • it only runs the main lane while Item::I_getMapArray(item) == 0
  • for frame == 2, it reads the current QLo as a base skill/link id and dispatches TRIGGER::ordinal20 lane 0 through three difficulty lanes: diff1 -> QLo, diff2 -> QLo + 1, diff3+ -> QLo + 2
  • for the non-frame 2 forms, it does not rewrite the link id; instead it compares difficulty against frame + 2 and dispatches TRIGGER::ordinal20 lane 0 below that threshold and lane 1 at or above it

That makes the current best read:

  • 0x0120 is a fast-area skill gate/controller, not a generic editor cube
  • frame 0 flips at difficulty 2 (diff1 -> lane 0, diff2+ -> lane 1)
  • frame 1 flips at difficulty 3 (diff1/2 -> lane 0, diff3+ -> lane 1)
  • frame 2 is the explicit skill-routing form that remaps the downstream trigger QLo by +0/+1/+2

For editor arrows, the strongest conservative rule is the same local controller rule already used for other trigger sources, but with the frame-2 rewrite made explicit.

  • FASTSKIL can point to nearby 0x04B1 controller helpers when they live in the same local trigger cluster
  • for frame 0 and frame 1, the relevant local link key is the source item's current QLo
  • for frame 2, the relevant local link keys are QLo, QLo + 1, and QLo + 2, matching the recovered difficulty lanes
  • one concrete Remorse map 13 example is especially strong: a FASTSKIL placement and a 0x04B1 controller helper sit at the same coordinates (35390, 20894) with matching raw quality 283 / QLo 27

This still needs to stay conservative.

  • broader scene sweeps do not justify treating every nearby 0x04B1 as a FASTSKIL target
  • the renderer should therefore stay on the existing local-distance plus explicit QLo lane rule instead of inventing a wider object-class relationship
  • the tooltip should explain the frame-specific trigger lane or QLo rewrite directly so the object reads as a trigger program rather than as another anonymous skill box

Switch And Helper Follow-Up

This pass tightened several of the still-generic switch/helper shapes that sit next to egg workflows.

0x00A1 frame 0: PANELNS

This one now has a direct recovered class label.

  • Usecode class 161 (00A1 PANELNS)
  • recovered handler: PANELNS::use()

The body is small and consistent with a plain directional wall panel switch.

  • if frame == 0, the handler returns immediately
  • if Item::I_getMapArray(item) != 0, the handler also exits without firing
  • otherwise it plays SFX 0xAC and dispatches TRIGGER::ordinal20 with lane 0

Current best read:

  • 0x00A1 frame 0 is the idle/inactive visual state of a panel switch family
  • the important authored controller key is still downstream QLo, because the spawned TRIGGER path uses that link namespace rather than mapNum or npcNum
  • sampled Remorse scene caches show nearby same-QLo 0x04B1 controller helpers often enough to support editor arrows as a local-assistance layer

0x031D frame 0: CARD_NS

This one also closes cleanly from the recovered usecode corpus.

  • Usecode class 797 (031D CARD_NS)
  • recovered handler: CARD_NS::use()

CARD_NS::use() is only a thin wrapper. It immediately spawns SWITCH::ordinal21 on the source item.

SWITCH::ordinal21 is the more useful body for editor work.

  • it reads Item::I_getQLo(item) and passes that to MainActor::I_hasKeycard(...)
  • if the switch is already in frame 4, it routes straight into TRIGGER::ordinal20 lane 0
  • otherwise, with the right card and no alert-state block, it flips to frame 4, plays the authorization SFX lane, then dispatches TRIGGER::ordinal20 lane 0
  • the denied path eventually dispatches TRIGGER::ordinal20 lane 1

Current best read:

  • 0x031D frame 0 is a locked north/south keycard switch
  • QLo is the keycard id and the strongest local link field for controller arrows
  • same-map same-QLo 0x04B1 matches appear in sampled Remorse scenes, so the renderer can expose them as editor-helper arrows without claiming that the switch talks directly to those helpers in one hardcoded object-only lane

0x03AA frame 0: SPANEL

This shape now has both a direct class label and a second helper-side link path.

  • Usecode class 938 (03AA SPANEL)
  • recovered handler: SPANEL::use()

The direct switch body is simple.

  • it exits when mapArray != 0
  • otherwise it conditionally plays SFX 0xAF for the early-map lane
  • it then dispatches TRIGGER::ordinal20 lane 0

That closes the same switch-style controller story as PANELNS, with QLo remaining the practical local link key through the downstream TRIGGER chain.

SPANEL also appears on the target side of another helper family.

  • BRO_BOOT::enterFastArea() scans nearby shape 0x03AA items
  • it compares them by shared QLo
  • depending on the active mission/global branch, it applies ITEM::ordinal23 or ITEM::ordinal24 to those matching SPANEL items

Current best read:

  • 0x03AA frame 0 is a switch panel source in the trigger/controller ecosystem
  • it is also a helper target for BRO_BOOT
  • broader exported-scene sweeps now show repeated local same-QLo BRO_BOOT -> SPANEL matches on Remorse maps 9, 10, 11, 21, 23, 160, and 246, so the renderer can now expose that helper lane conservatively too

0x0366 frame 0: NPC_ONLY

The earlier placeholder label is now closed.

  • Usecode class 870 (0366 NPC_ONLY)
  • recovered handler: NPC_ONLY::gotHit(uword, word)

The body is a gated trigger pad rather than a generic editor helper.

  • it rejects the hit unless the incoming actor/reference resolves to an NPC-like source
  • it reads Actor::I_GetNPCDataField0x63_00B(actor)
  • it reads Item::I_getQLo(item) from the pad and requires those values to match
  • with mapArray == 0 and the occupancy checks satisfied, it flips to frame 1, waits briefly, and dispatches TRIGGER::ordinal20 lane 0
  • once the actor is no longer satisfying the occupancy lane, it restores frame 0 and dispatches TRIGGER::ordinal20 lane 1

Current best read:

  • 0x0366 frame 0 is an idle NPC-only trigger pad
  • QLo is the author-selected NPC-group key, but the executable compares it against actor field 0x63, not against the scene-export npcNum or a simple DTABLE row
  • current scene exports do not expose that actor-side field directly, so there is not yet enough evidence to draw trustworthy NPC_ONLY -> actor arrows in the renderer
  • a follow-up scene-cache sweep also failed to produce a convincing generic NPC_ONLY -> 0x04B1 helper pattern; shared-QLo local matches look incidental rather than like a dedicated authored link lane

That means the safe editor improvement here is naming and field emphasis, not a target-arrow rule yet.

0x0403 frame 0: FLAMEBOX

This shape now has a direct class label and a usable local arrow rule.

  • Usecode class 1027 (0403 FLAMEBOX)
  • recovered handler: FLAMEBOX::equip(sint16)

The recovered body has two nearby-helper lanes keyed by shared QLo.

  • event 0 scans nearby flame-family shapes 0x043A, 0x043B, 0x050A, and 0x0518
  • event 1 scans nearby flame editor/helper shapes 0x0438 and 0x0439
  • both lanes compare helper items by Item::I_getQLo(...) == Item::I_getQLo(flamebox)
  • the event-1 lane can replace the helper marker with an animated flame actor and then spawns the flame process

Scene-cache checks on Remorse map 1 line up with that lane strongly enough to expose in the editor.

  • FLAMEBOX qlo 24 at (61310,60830) has nearby helper shape 0x0438 at (61886,60798)
  • FLAMEBOX qlo 7 at (60350,49406) has nearby helper shape 0x050A at (59938,49294)
  • FLAMEBOX qlo 21 at (59166,5214) has nearby shape 0x0439 matches at (58814,5790) and (59166,5790)

Current best read:

  • 0x0403 frame 0 is a local flame controller
  • QLo is the authored flame channel id
  • editor-helper arrows from FLAMEBOX to nearby same-QLo flame helper shapes are evidence-backed and now justified

0x04E7 frame 0: DEATHBOX

The existing npc death nickname was directionally right but incomplete. The recovered class name and caller path now narrow the object down.

  • Usecode class 1255 (04E7 DEATHBOX)
  • recovered helper body: DEATHBOX::func0A(sint16)
  • recovered caller path: NPCDEATH::ordinal20 scans nearby shape 0x04E7 items directly

The strong executable-side lane is in NPCDEATH::ordinal20.

  • it iterates nearby DEATHBOX items
  • it compares the incoming death-link value against Item::I_getQLo(deathBox)
  • if Item::I_getMapArray(deathBox) == 0, it dispatches TRIGGER::ordinal20 lane 0 from the helper item
  • otherwise it reads QHi and npcNum from the helper, can create a keycard with frame QHi - 1, can override that keycard's QLo from helper npcNum, and then dispatches the 0x80 trigger lane

That makes the current best editor-facing read:

  • 0x04E7 is an NPCDEATH helper/controller, not just a raw death marker
  • QLo is the death-link match key
  • QHi carries drop/helper mode information, including the verified keycard lane for values 1..4
  • npcNum can act as a secondary payload for the spawned keycard helper path

Current limitation:

  • the static scene export does not yet expose the originating NPC death-link values directly enough to draw reliable DEATHBOX -> source NPC arrows
  • some maps do show local same-QLo 0x04B1 helpers near DEATHBOX, but the recovered executable path is still clearest as NPC death event -> DEATHBOX -> TRIGGER, and a broader follow-up sweep did not justify promoting DEATHBOX -> 0x04B1 into a generic arrow rule

0x04FE frame 0 / frame 1: BRO_BOOT

This shape is no longer mysterious, even though its local authored targets are not yet consistently visible in sampled scenes.

  • recovered class label: BRO_BOOT
  • recovered handlers include BRO_BOOT::enterFastArea() and BRO_BOOT::leaveFastArea()

BRO_BOOT::enterFastArea() does three important things.

  • it branches on a mission/global state lane (global [001F 01] in the recovered output)
  • it scans nearby shape 0x03AA items and compares them by shared QLo
  • it applies ITEM::ordinal23 or ITEM::ordinal24 to those matching SPANEL items, then runs its own boot-style frame animation loop

The broader scene cache now supports that link well enough for a renderer overlay.

  • follow-up exported-scene sweeps found repeated local same-QLo BRO_BOOT -> SPANEL matches across Remorse maps 9, 10, 11, 21, 23, 160, and 246
  • one concrete Remorse map 10 example has SPANEL item 4538 at (21018, 13406) with a nearby BRO_BOOT at (20968, 13784), both carrying QLo 50

Current best read:

  • 0x04FE is a scripted helper tied to nearby SPANEL items, not a free-form generic editor object
  • frame 0 and frame 1 are boot-sequence animation states, not independent target ids
  • QLo is the local authored linkage field for the helper-to-SPANEL lane

Current renderer stance:

  • this is strong enough to relabel the family as BRO_BOOT
  • the exported-scene sweep is now broad enough to justify a generic local BRO_BOOT -> SPANEL arrow layer based on shared QLo plus local distance

0x0080 frame 0: BOX_EW

This one closes as another switch-family trigger source.

  • Usecode class 128 (0080 BOX_EW)
  • recovered handler: BOX_EW::use()

The recovered body is compact but useful.

  • if mapArray != 0, it exits without firing
  • frame 0 plays the switch SFX and dispatches TRIGGER::ordinal20 lane 1
  • nonzero frames dispatch TRIGGER::ordinal20 lane 0

The map-side correlation is strong enough to promote a conservative helper-arrow rule, but only for the idle switch state.

  • sampled exported scenes show repeated local same-QLo BOX_EW frame 0 -> 0x04B1 matches, including co-located pairs on Remorse map 9
  • the same broader sweep did not justify treating nonzero BOX_EW frames as the same generic cmd-link source lane

Current best read:

  • 0x0080 is the BOX_EW switch family
  • QLo remains the practical authored controller key for nearby helper scans
  • the renderer can safely expose BOX_EW frame 0 -> nearby 0x04B1 arrows when local distance and QLo both match

0x04CD frame 0: TRIGPAD

This family now has a direct class label, but not a new helper-arrow rule.

  • Usecode class 1229 (04CD TRIGPAD)
  • recovered handler: TRIGPAD::gotHit(...)

The recovered body behaves like a heavier floor trigger.

  • it gates on occupancy and surface checks before the pad arms
  • with mapArray == 0, it waits briefly, dispatches TRIGGER::ordinal20 lane 0, and later dispatches lane 1 as the condition clears
  • the same body also loops across nearby elevator-family items and can call ELEVAT control slots in that path

Current best read:

  • 0x04CD is a real trigger-pad class, not a generic editor placeholder
  • it belongs in the trigger/helper ecosystem, but its executable behavior is broader than a simple QLo -> cmd-link switch
  • the wider exported-scene sweep did not justify promoting a generic local TRIGPAD -> 0x04B1 arrow rule, so the safe editor improvement is labeling plus tooltip decoding only

0x033A frame 0: NUMBERS

This family still does not have a cleaner recovered usecode class label than the catalog-facing name, but the scene role is much less ambiguous now.

  • exported scenes show 0x033A as tiny glyph-sized frames, typically 10x16 or 12x16
  • those items repeatedly cluster beside larger sibling shapes such as 0x0501, 0x0502, 0x0503, 0x0505, and 0x0507
  • the same map-side checks do not make it look like another trigger or cmd-link helper family

Current best read:

  • 0x033A is best treated as a number/readout display helper family
  • the renderer should label it clearly so editor users can spot display clusters
  • current evidence does not justify helper arrows from NUMBERS

Editor Helper Arrow Overlay

The renderer now exposes a separate nested toggle under Show editor-only elements called Show editor helper arrows.

That overlay is intentionally narrower than the existing verified teleport/elevator arrow layer. It only draws local helper relationships that have direct usecode support and an evidence-backed authored key:

  • ALARMHAT (0x0561) -> nearby 0x04D0 helpers, using the recovered local alarm scan behavior
  • STEAMBOX (0x0500) -> nearby steam-family targets, matched by shared QLo
  • 0x04F8 -> nearby destroyable door-family targets, matched by shared QLo and local placement
  • BRO_BOOT (0x04FE) -> nearby SPANEL targets, matched by shared QLo
  • PANELNS (0x00A1), CARD_NS (0x031D), and SPANEL (0x03AA) -> nearby 0x04B1 controller helpers when they share the same local QLo
  • BOX_EW (0x0080) frame 0 -> nearby 0x04B1 controller helpers when they share the same local QLo
  • FLAMEBOX (0x0403) -> nearby flame helper shapes, matched by shared QLo
  • EVENT (0x0361) and SKILLBOX (0x04E3) -> nearby 0x04B1 controller helpers when they share the same local QLo
  • FASTSKIL (0x0120) -> nearby 0x04B1 controller helpers on the same local trigger lanes, with frame 2 also exposing the recovered QLo + 1 and QLo + 2 difficulty variants
  • ALRMTRIG (0x0581) -> nearby 0x04B1 controller helpers by the same lane byte, as a weaker relay-style visualization rather than a claimed final object identity
  • 0x04B1 -> nearby exact decoded target shapes when the TRIGGER field split resolves to a concrete target shape and local same-QLo candidates exist

Two newly decoded families stay intentionally out of the arrow layer for now.

  • TRIGPAD (0x04CD) is now named and decoded in tooltips, but the broader scene sweep did not justify a generic TRIGPAD -> 0x04B1 helper rule
  • NUMBERS (0x033A) looks like a display/readout marker family rather than a trigger/controller source, so it stays label-only

The important constraint is that these arrows are editor-assistance overlays, not canonical gameplay graphs. They are meant to expose likely local controller lanes without pretending that every helper object participates in one global id namespace.

Live Ghidra Notes

The live CRUSADER.EXE Ghidra database now also has comment-backed notes at 1020:029e and 1020:02d0 documenting the startup teleporter precedence that matters for map/egg overrides:

  • if the X override remains -1, startup stays on the teleporter/egg path
  • even when X/Y/Z coordinates are present, a nonnegative -egg override still wins and routes through Teleporter_CreateProcessDirect

That executable-side note is adjacent to the broader teleporter system rather than to 0x01DB specifically, but it closes one important runtime rule for interpreting teleport-id driven startup behavior.

Outcome For The Feature

The egg browser added to the renderer now:

  • lists every egg-family item in the loaded map
  • shows the decoded id appropriate to that egg family
  • can center the camera on a chosen egg and pin-select it
  • can draw zoom-stable id labels over eggs in the viewport

Remaining Uncertainty

Two things are still worth keeping in mind:

  • family 7 Crusader handling is still marked in ScummVM as partly suspicious because those records can also behave container-like
  • 250 = weapons room is a strong workflow convention, but this investigation did not add a new executable-side proof beyond the catalog evidence and known reverse-engineering notes