Crusader_Decomp/docs/entity-dispatch-entry-class-layout.md
2026-04-09 00:32:12 +02:00

18 KiB

EntityDispatchEntry Class Layout

Purpose

This note is the first focused class-layout working paper for the Remorse C++ lift.

It takes the broad EntityDispatchEntry* inventory entry and narrows it into a base/derived object model that can later be pushed into Ghidra as class namespaces, instance structs, vtable structs, and method ownership.

The goal is not to claim a final C++ API. The goal is to lock down the pieces that are already stable enough to support later implementation work.

Why This Family Goes First

EntityDispatchEntry is the strongest current pilot family because it already has:

  • a clear constructor-style base init path
  • multiple derived constructor variants
  • explicit owned state and word-list teardown
  • stable field groups with known offsets
  • repeated virtual-slot dispatch through known offsets
  • strong caller evidence across scheduler, runtime-state, palette, and startup/display lanes

That makes it the best place to prototype the full later workflow:

  • class namespace creation
  • method ownership
  • instance-struct typing
  • vtable typing
  • base/derived split
  • later C++ skeleton emission

Candidate High-Level Model

Current best working split:

  • EntityDispatchEntryBase
  • EntityDispatchEntryTimed or EntityDispatchEntryPeriodic for the 0x3aa6 timing/period variant
  • EntityDispatchEntryRuntimeState for the later 000d:7e00/8078 runtime-state owned-buffer family

This should stay a working model, not a hard rename, until the class work lands in Ghidra.

Base Constructor Surface

0008:ba00 entity_dispatch_entry_init

Current best read:

  • optional allocate/init path for a 0x32-byte base object
  • stamps base vtable/list-link state using 0x3b06, 0x2d10, and 0x3afe
  • zeroes core state fields
  • seeds the group/layer byte through entity_set_group_id

This is the strongest current candidate for the base constructor-style init method.

Derived constructor variants

0008:cefb entity_dispatch_entry_ctor_vtbl_3ad2

  • allocates if null
  • reinitializes through entity_dispatch_entry_init
  • sets vtable 0x3ad2
  • sets flag 0x100 at +0x16
  • zeroes extension words +0x32/+0x34

0008:d214 entity_dispatch_entry_ctor_vtbl_3aa6

  • allocates 0x40 bytes if null
  • reuses 0008:cefb
  • sets vtable 0x3aa6
  • sets flag 0x200 at +0x16
  • zeroes fields +0x38..+0x3e
  • 0004:ea00 entity_dispatch_entry_alloc_type_0f5e
  • 0004:eb1f entity_dispatch_entry_ctor_0f3a_with_cache_reset

These look more like subtype-specific factory/create helpers than pure base constructors, but they still belong in the family map.

Destroy / Release Surface

Base-owned word-list destruction

0008:dbec entity_word_list_destroy

  • resets vtable to 0x2d10
  • frees list storage if present
  • optionally frees object when destroy flag bit 1 is set

This is the clearest current destructor-style path on the base object.

Runtime-state release

000d:8078 entity_dispatch_entry_release_runtime_state

  • frees paired owned buffers
  • updates shared hold/owner propagation through g_active_dispatch_entry_farptr
  • destroys embedded word-list members

This reads as the release/destructor path for the runtime-state derived family rather than for the whole base type.

Current Base Layout

This table is a working layout, not a finished header.

Offset Current name Confidence Current meaning
+0x00 type_or_kind Medium Constructor/factory helpers stamp type words such as 0x0f3a, 0x0f5e, or 0x051e here in some subfamilies. Base-vtable interpretation remains separate.
+0x02 slot_index_or_count Medium Used as entry slot/index in several wrappers; also used as count in the base word-list family, so exact role may vary by subtype or overlay.
+0x04 source_type High Written by entity_set_source_type.
+0x06 event_type_or_list_ptr_lo Medium Written by entity_set_event_type_checked, but also participates in word-list storage in the list-owning variant. This is likely one of the current overlay collisions to resolve later.
+0x08 group_id_byte High Low 5-bit group/layer value managed by entity_set_group_id.
+0x0a/+0x0c/+0x0e/+0x10 link_or_state_words High Cleared by entity_dispatch_entry_unlink; belong to link/extent/target/reset state.
+0x12/+0x14 target_farptr High Managed by entity_flag20_*_target helpers.
+0x16 flags1 High Holds bits 0x10, 0x20, 0x100, 0x200, 0x4000, and other subtype/state gates.
+0x18 flags2 High Holds bits 0x40, 0x80, 0x100, 0x400, 0x1000; used by unlink, periodic, and refresh paths.
+0x1e/+0x28 embedded_dispatch_or_word_list_members Medium Many callsites treat these as subobject or vtable-dispatch bases. Exact split still needs a dedicated subobject note.
+0x24/+0x26, +0x2e/+0x30 optional_member_ptrs Medium Checked before freeing both embedded word-list members.
+0x32/+0x34 extension_words_a High Zeroed by the 0x3ad2 constructor variant; also used by later runtime/VM helper flows.
+0x36/+0x38/+0x3a period_or_schedule_words Medium Written by entity_set_update_period_and_reschedule; clearly timing-related in the periodic variant.
+0x3c/+0x3e accumulator_words High Used by entity_periodic_accumulate_and_dispatch.
+0x40 hold_token High Shared/borrowed hold byte in startup/display and runtime-state families.
+0x41/+0x42/+0x44 runtime_state_flags High Initialized by entity_dispatch_entry_init_runtime_state.
+0x46/+0x48 owned_buffer_a High Runtime-state owned work/palette-like buffer.
+0x4a/+0x4c owned_buffer_b High Second runtime-state owned buffer.
+0x49 file_family_selector High for the seg126 subtype Local selector state in startup/display transition family. Likely subtype-specific, not general base meaning.
+0x5b state_flags High for the seg126 subtype State-machine bits in the 000c startup/display lane. Likely subtype-specific overlay.
+0x520 selected_resource Medium Loaded file/resource object in the transition-file-family subtype.

Important Layout Caveat

This family is almost certainly not one flat struct with universally stable semantics at every offset. Current evidence already shows subtype overlays:

  • base scheduler/dispatch-entry state
  • word-list-owning variants
  • periodic/timer variants
  • startup/display transition variants
  • runtime-state/palette-backed variants

So the safest future Ghidra modeling strategy is:

  1. create a minimal EntityDispatchEntryBase
  2. create derived or overlay structs for subtype-specific tails
  3. avoid prematurely forcing every offset into one monolithic universal class layout

Candidate Method Map

Strong base methods

Address Current function Candidate method role
0008:ba00 entity_dispatch_entry_init InitBase()
0008:bbb6 entity_set_source_type SetSourceType()
0008:bc27 entity_set_event_type_checked SetEventTypeChecked()
0008:bca8 entity_set_group_id SetGroupId()
0008:bd53 entity_dispatch_entry_unlink Unlink()
0008:be05 entity_increment_group_id IncrementGroupId()
0008:c01d entity_refresh_dispatch_state RefreshDispatchState()
0008:bfb2 entity_clear_status_bits_from_flags ClearStatusBitsFromFlags()
0008:bf8e entity_call_update_vfunc14 CallUpdateSlot14()
0008:beee entity_run_flagged_handlers RunFlaggedHandlers()
Address Current function Candidate method role
0008:c7f1 entity_pair_update_link_slot_a UpdateLinkSlotA()
0008:c890 entity_pair_update_link_slot_b UpdateLinkSlotB()
0008:c92f entity_pair_sync_a PairSyncA()
0008:ca18 entity_pair_sync_b PairSyncB()
0008:c9ee entity_pair_mark_and_sync_a MarkAndPairSyncA()
0008:cad7 entity_pair_mark_and_sync_b MarkAndPairSyncB()
0008:cb2c entity_flag20_clear_and_update_target ClearFlag20AndUpdateTarget()
0008:cb5c entity_flag20_set_and_init_target SetFlag20AndInitTarget()

Periodic/timed subtype methods

Address Current function Candidate method role
0008:cefb entity_dispatch_entry_ctor_vtbl_3ad2 ConstructVtable3AD2()
0008:d214 entity_dispatch_entry_ctor_vtbl_3aa6 ConstructVtable3AA6()
0008:d313 entity_periodic_accumulate_and_dispatch TickPeriodic()
0008:d3e6 entity_set_flag2000_and_update_active_counters EnableActiveCounters()
0008:d433 entity_clear_flag2000_and_update_active_counters DisableActiveCounters()
0008:d27e entity_set_update_period_and_reschedule SetUpdatePeriodAndReschedule()

Word-list-owning subtype methods

Address Current function Candidate method role
0008:da00 entity_word_list_set_0408_terminated SetWordList0408Terminated()
0008:dba3 entity_word_list_free_existing FreeWordList()
0008:dbec entity_word_list_destroy Destroy()
0008:dc38 entity_word_list_ensure_contains EnsureWordListContains()
0008:dcab entity_word_list_append_unique AppendUniqueWord()
0008:ddaf entity_word_list_remove_value RemoveWordValue()
0008:deea entity_word_list_get_at GetWordAt()
0008:df1b entity_word_list_set_at SetWordAt()
0008:dfa1 entity_word_list_find_unflagged_by_id10 FindUnflaggedWordById10()

Runtime-state subtype methods

Address Current function Candidate method role
000d:7e00 entity_dispatch_entry_init_runtime_state InitRuntimeState()
000d:8078 entity_dispatch_entry_release_runtime_state ReleaseRuntimeState()

Candidate Virtual Surface

The current evidence does not justify a fully named vtable yet, but some slot use is already real:

  • +0x14 = update callback slot used by entity_call_update_vfunc14
  • +0x28 = callback slot used by the periodic and proximity-style dispatch helpers
  • embedded subobject/member surfaces at +0x1e and +0x28 are also dispatched through helper wrappers in far-call-targets.md

Recommended future vtable note shape:

Slot offset Current best role Evidence quality
+0x14 update/refresh callback High
+0x28 periodic/dispatch callback High
others unknown/default stubs Low

Safe Future Ghidra Modeling Steps

When manual class work starts, the safest order for this family is:

  1. create class namespace EntityDispatchEntry
  2. move only the strong base methods first
  3. create minimal EntityDispatchEntryBase struct with the stable fields through +0x18
  4. create subtype overlay structs for word-list, timed, and runtime-state tails
  5. create a small provisional vtable for only the verified slots

Do not start by forcing one complete 0x520-byte monolithic class.

Live Ghidra Authoring Status

Verified first live EntityDispatchEntry shell batch landed on 2026-04-07.

  • Created class owner Remorse::EntityDispatchEntry in the active CRUSADER.EXE database.
  • Created provisional base datatype /Remorse/EntityDispatchEntryBase with the current stable field block through +0x18:
    • type_or_kind
    • slot_index_or_count
    • source_type
    • event_type_or_list_ptr_lo
    • group_id_byte
    • link_or_state_word_0a..10
    • target_farptr
    • flags1
    • flags2
  • Created provisional vtable datatype /Remorse/EntityDispatchEntryVtable with only the two currently verified callback slots exposed:
    • +0x14 = update_callback_slot14
    • +0x28 = dispatch_callback_slot28
  • Kept the remaining vtable lanes explicit as unresolved padding instead of inventing slot names too early.
  • Did not move methods yet. The current source note still anchors this family through the older 0008: / 000d: address notation, and those entrypoints are not yet mapped into the active live CRUSADER.EXE session strongly enough to justify owner moves by guesswork.
  • The current live MCP apply_class_layout(...) path also rejected the minimal shell bind with an undocumented required methods property, so this first shell pass used the now-working live run_write_script(...) fallback to author the class namespace and datatypes directly.

This means the family is now started in-session rather than remaining note-only, but it is still in the pre-method phase.

That is no longer true after the next live pass on 2026-04-07.

  • The older 0008:ba00 pilot cluster is now re-anchored in the live CRUSADER.EXE session as the 11e0: process-substrate segment by direct offset mapping from the decompiler's embedded original-segment metadata.
  • The first strong base-method batch is now moved under Remorse::EntityDispatchEntry:
    • 11e0:0000 -> InitBase (older note anchor 0008:ba00)
    • 11e0:01b6 -> SetSourceType (older note anchor 0008:bbb6)
    • 11e0:0227 -> SetEventTypeChecked (older note anchor 0008:bc27)
    • 11e0:02a8 -> SetGroupId (older note anchor 0008:bca8)
    • 11e0:0353 -> Unlink (older note anchor 0008:bd53)
    • 11e0:0405 -> IncrementGroupId (older note anchor 0008:be05)
  • Those six methods now carry provisional EntityDispatchEntryBase * this signatures in-session plus decompiler comments recording the old 0008: provenance so later note cleanup does not have to re-derive the mapping.
  • The current live surface is still deliberately conservative. The decompiler still shows the underlying struct_Process substrate in several bodies, so this batch should be treated as class ownership plus field-layout alignment, not proof that every inherited process-style helper name is final.
  • The next derived-family batch is now landed too. The older runtime-state pair from the note's 000d: anchors is re-anchored in the live 1440: fade/palette cluster by explicit decompiler segment metadata and matching offset delta:
    • 1440:0000 FadeProcess_Create -> Remorse::EntityDispatchEntry::InitRuntimeState (older note anchor 000d:7e00)
    • 1440:0278 FUN_1440_0278 -> Remorse::EntityDispatchEntry::ReleaseRuntimeState (older note anchor 000d:8078)
  • Created /Remorse/EntityDispatchEntryRuntimeState as a provisional overlay datatype. It preserves the stable base block through +0x18, keeps +0x1a..+0x3f explicit as unresolved subtype overlay space, and names the recovered runtime-state tail fields:
    • +0x40 = hold_token
    • +0x41 = runtime_state_flag_41
    • +0x42 = runtime_state_counter_42
    • +0x44 = runtime_state_delta_44
    • +0x46/+0x48 = owned_buffer_a
    • +0x4a/+0x4c = owned_buffer_b
  • Those two runtime-state methods now carry provisional EntityDispatchEntryRuntimeState * this signatures and in-session comments tying them back to the older 000d: evidence, which is enough to treat the runtime-state lane as class-authored rather than only documented.
  • The next derived-family step is now landed too for the periodic/timed branch in the live 11e0: substrate segment. Six more methods are re-anchored from the older 0008: note cluster by preserved offset delta from 0008:ba00 -> 11e0:0000 and now live under Remorse::EntityDispatchEntry with short provenance comments:
    • 11e0:14fb Process_Create_0x36byte -> ConstructVtable3AD2 (older note anchor 0008:cefb)
    • 11e0:1814 Process_Init0x40ByteProc -> ConstructVtable3AA6 (older note anchor 0008:d214)
    • 11e0:187e Process_Set_MaybeTimesPerSecond -> SetUpdatePeriodAndReschedule (older note anchor 0008:d27e)
    • 11e0:1913 FUN_11e0_1913 -> TickPeriodic (older note anchor 0008:d313)
    • 11e0:19e6 Process_11e0_19e6 -> EnableActiveCounters (older note anchor 0008:d3e6)
    • 11e0:1a33 Process_11e0_1a33 -> DisableActiveCounters (older note anchor 0008:d433)
  • The earlier word-list blocker is now closed too, but by re-anchoring rather than by 11e0: boundary repair. The expected live 11e0:2000..25a1 window is not code in the current database; the actual word-list-owned subtype lives in the 11e8: MList_* cluster, with the root at 11e8:0000 carrying explicit old 0008:da00 segment metadata in the decompiler. That full batch now also lives under Remorse::EntityDispatchEntry with short provenance comments:
    • 11e8:0000 MList_11e8_0000 -> SetWordList0408Terminated (older note anchor 0008:da00)
    • 11e8:01a3 MList_11e8_01a3 -> FreeWordList (older note anchor 0008:dba3)
    • 11e8:01ec MList_11e8_01ec -> Destroy (older note anchor 0008:dbec)
    • 11e8:0238 FUN_11e8_0238 -> EnsureWordListContains (older note anchor 0008:dc38)
    • 11e8:02ab MList_11e8_02ab -> AppendUniqueWord (older note anchor 0008:dcab)
    • 11e8:03af MList_11e8_03af -> RemoveWordValue (older note anchor 0008:ddaf)
    • 11e8:04ea MList_GetInt16 -> GetWordAt (older note anchor 0008:deea)
    • 11e8:051b MList_11e8_051b -> SetWordAt (older note anchor 0008:df1b)
    • 11e8:05a1 FUN_11e8_05a1 -> FindUnflaggedWordById10 (older note anchor 0008:dfa1)
  • That correction matters more than the names alone. The pilot family is no longer blocked on a missing word-list method surface; the remaining uncertainty is now about how explicitly the word-list-owned subtype should be split in datatypes and eventual C++ modeling, not about whether those methods exist in live CRUSADER.EXE.

Questions To Close Later

  • whether the live 11e8: word-list-owned subtype should stay modeled as a method batch under EntityDispatchEntry alone or be split further into an explicit derived/overlay class once a safe instance-size boundary is chosen
  • whether +0x00 should be modeled as a literal kind field in all variants or only in some factory-built subtypes
  • exact ownership split between the base object and the embedded surfaces at +0x1e and +0x28
  • whether the seg126 startup/display subtype is truly part of the same inheritance family or only shares a lower-level dispatch-entry substrate
  • final base-size versus subtype-size boundaries once class namespaces exist in Ghidra

Immediate Next Documentation Value

The next best companion note after this one is a slot-focused SpriteNode virtual table note, because that gives a second family with a cleaner explicit virtual surface and helps calibrate how aggressive the first Ghidra class conversion should be.