Crusader_Decomp/docs/raw-000a-000d.md
MaddoScientisto daa363c3d2 Add 'annotate-usecode' command to import USECODE IR JSON annotations
- Introduced a new command 'annotate-usecode' to import USECODE IR JSON annotation hints as Ghidra comments on compiled anchors.
- Added argument parsing for multiple IR JSON files, comment type selection, and a dry-run option.
- Implemented logic to read annotation records from the provided IR files and set comments on the corresponding addresses in Ghidra.
- Enhanced JSON schema to include response structure for the new command.
2026-03-24 18:14:20 +01:00

72 KiB
Raw Blame History

Raw 000a & 000d: Tracked Handles, Cache Manager & Proximity Buckets

Content extracted from crusader_decompilation_notes.md. Covers the 000d proximity/visibility bucket cluster, 000a tracked-handle table, generic cache manager, seg082 allocator, seg137/138 palette helpers, and seg004/seg005 startup paths.


Raw 000d Proximity/Visibility Bucket Cluster

Small conservative rename batch from the 000d:cc00-d413 region.

Address Name Evidence
000d:cc00 entity_compute_proximity_or_visibility_bucket Returns bucket 0x40 for null or on-screen entities (entity_projected_bbox_overlaps_viewport), else computes a distance bucket from the current reference entity at 0x7e22 with thresholds 0x17d, 0x281, 0x3c1 mapping to 0x32, 0x20, 0x10, 0x08
000d:d413 entity_refresh_recent_proximity_or_visibility_buckets Walks the last four active records in the 0x69ac array, recomputes the same bucket, stores it back to each entry, and calls 000a:6343 when the bucket changes
000d:cdd0 tracked_entity_bucket_prune_invalid_entries Walks the 0x69ac array, validates backing handles through 000a:637a, and clears entry handles to 0xffff when the backing object is gone
000d:cd62 tracked_entity_bucket_find_free_main_slot Finds the first free entry in the main portion of the 0x69ac array (0 .. count-4)
000d:cd9a tracked_entity_bucket_find_free_aux_slot Finds the first free entry in the auxiliary tail portion of the 0x69ac array (count-4 .. count-1)

Supporting caller notes

  • 000d:ce1e populates one 0x69ac entry by reserving a free slot, computing the initial bucket through entity_compute_proximity_or_visibility_bucket, storing both current and previous bucket fields, then allocating/linking the backing handle through 000a:5f36.
  • 000d:d409 is a thin wrapper that only calls entity_refresh_recent_proximity_or_visibility_buckets.
  • 000d:cfad is an update-or-allocate helper for (param_1,param_2) pairs: it tries to update an existing tracked entry through 000a:606a, clears dead entries, and falls back to 000d:ce1e allocation when no live match remains.
  • 000d:cec5 is the auxiliary-slot allocator: it prunes invalid entries, uses tracked_entity_bucket_find_free_aux_slot, tags the new entry with byte +0x0a = 1, and seeds its handle via 000a:5f36(..., flag=1).
  • 000a:606a = tracked_entity_bucket_handle_update_or_alloc — updates the backing handle for an existing tracked bucket entry when possible, or falls back to allocation via 000a:5f36 if the handle has gone stale.
  • 000d:d350 = tracked_entity_bucket_set_value — finds a tracked (entity_id, entity_ref) entry and pushes a new bucket value into its backing handle through 000a:6343.
  • 000d:d10b = tracked_entity_bucket_clear_ref_field — clears only the +0x02 reference field for all matching entries.
  • 000d:d151 = tracked_entity_bucket_remove_by_ref — marks matching entries' backing handles for removal and clears the local entry handle/reference fields.
  • 000d:d1b1 = tracked_entity_bucket_remove_tagged_by_ref — same removal path, but only for entries whose byte +0x0a tag is set.

Raw 000a Tracked-Handle Table

The 0x4673 table is the backing handle registry for the 0x69ac tracked-entry bucket subsystem. That client layer sits on top of a separate generic cache manager rooted at 0x4688..0x46b7.

Address Name Evidence
000a:5f02 tracked_entity_handle_find_slot Linear scan over 12 entries in the 0x4673 table for a matching 32-bit handle id
000a:602b tracked_entity_handle_is_live Returns true only when a handle exists in 0x4673 and its flag word at +0x0a does not have bit 0x0002 set
000a:60eb tracked_entity_handle_mark_remove Sets bit 0x0002 in the handle-table flag word and dispatches through the unresolved cleanup path
000a:612e tracked_entity_handle_mark_remove_all Iterates all 12 handle-table entries and marks each live handle for removal
000a:6167 tracked_entity_handle_alloc_slot Allocates a slot in one of two ranges (0..7 or 8..11) depending on the aux flag; when full, wraps in a ring and evicts via tracked_entity_handle_mark_remove before reusing the slot
000a:6228 tracked_entity_handle_prune_removed Reaps entries previously marked with bit 0x0002, clears dead slots, and refreshes high-index entries through 000a:6b2d
000a:63bc tracked_entity_handle_find_by_entity Finds the first live handle-table entry whose key/entity word at +0x04 matches the requested entity id

Handle entry layout (stride 0x0c)

Offset Field
+0x00 32-bit handle id
+0x04 key/entity id
+0x06 class/group/source-style selector
+0x08 current bucket/value
+0x0a flags (bit0 = aux-slot allocation, bit1 = pending removal)

Thin public wrappers

Address Name
000a:5276 entity_bucket_track_default_main — gated by 0x45aa; creates or refreshes a main-slot tracked handle with bucket 0x40 and selector 0xff
000a:5294 entity_bucket_track_main — same path, but takes the bucket value as an argument for the main-slot range
000a:52d0 entity_bucket_track_default_aux — aux-slot variant with default bucket 0x40
000a:52ee entity_bucket_track_aux — aux-slot variant with explicit bucket argument

Raw 000a Generic Cache Manager

Follow-up analysis of 000a:6b2d and the 0x4688..0x46b7 globals shows that this region is a generic cache manager used by the tracked-handle layer, not part of the tracked-entity subsystem itself.

Address Name Evidence
000a:6b2d cache_lookup_or_load_entry_by_id Fast-paths the last id via 0x46af/0x46b1, otherwise searches 0x469d, evicts older cache slots until there is room under byte budget 0x46a5, allocates a block from the free-list, clears/initializes the payload, records the id, and dispatches through the loader interface at 0x468c
000a:6a95 cache_release_entry_by_slot Releases a cached slot by index, clears any client references through 000a:62d8, frees its backing block through cache_free_block_by_slot, and marks the slot id in 0x469d as unused (0xffff)
000a:6d07 cache_alloc_block_for_slot Allocates or splits a block from the free-list anchored at 0x4688, tags it with the owning cache slot index, and updates the in-use byte count at 0x46a9
000a:6f4d cache_free_block_by_slot Finds the free-list node for a cache slot, marks it free, subtracts its size from 0x46a9, and coalesces adjacent free blocks
000a:67d9 cache_shutdown Tears down the generic cache manager: flushes/reset state, frees slot arrays at 0x4699/0x469d/0x46b3, frees the free-list container at 0x4688, and closes backing state at 0x4691
000a:6898 cache_set_loader_interface Installs the backend loader/callback interface pointer at 0x468c

Cache globals

Address Name Notes
0x4688 free-list/block-list head Used by cache_alloc_block_for_slot and cache_free_block_by_slot
0x468c cache_loader_interface Backend callback table; +0x34 = size query, +0x0c = load/bind callback
0x4695 arena base pointer Base for the raw cache payload arena
0x4699 per-slot payload-pointer table
0x469d per-slot cached id table 0xffff = unused
0x46a5 byte budget / arena capacity
0x46a9 bytes currently in use
0x46af/0x46b1 fast-path cache Last requested id and slot index
0x46b3 per-slot block metadata mirror Used when releasing or refreshing slots

Follow-up: Cache Init and Runtime State

Address Name Notes
000a:6600 cache_init Stores slot count in 0x46ad; allocates per-slot payload-pointer table; seeds each slot; queries/derives arena size; allocates arena backing object at 0x4691; allocates per-slot metadata mirrors; initializes free-list head at 0x4688; calls cache_reset_runtime_state
000a:68aa cache_reset_runtime_state Shared cache reset/bootstrap helper called from cache_init, cache_shutdown, and external reset paths. Allocates per-slot arena-header nodes, rebinds slot pointers to arena base, clears the cached-id table, seeds the free-list head, and resets 0x46a9 (bytes in use) plus 0x46af (last-id fast path)
000a:703e cache_compact_arena_blocks Compacts live cache arena blocks into earlier free holes when allocation would fail, updates per-slot payload pointers, and merges adjacent free-list headers afterward

Follow-up: Tracked-Handle Table Init/Shutdown

Address Name Notes
000a:5e00 tracked_entity_handle_table_init If 0x4672 is clear, allocates 0x90 bytes at 0x4673/0x4675, aborts through runtime_init_or_abort on failure, calls 000a:577d and local helper 000a:5e95, then sets 0x4672 = 1
000a:5e59 tracked_entity_handle_table_shutdown Matching teardown for tracked_entity_handle_table_init
000a:5e95 tracked_entity_handle_table_clear_and_dispatch When tracked_entity_handle_table_active is set, zeroes the full 0x90-byte handle table at 0x4673, resets adjacent local state at 0x4677/0x4679/0x467b, then dispatches through the remaining thunked follow-up path
000a:5339 tracked_entity_handle_mark_remove_all_if_enabled Thin gate wrapper that only forwards to tracked_entity_handle_mark_remove_all when tracked_entity_bucket_system_enabled is set

Table globals: 0x4672 = tracked_entity_handle_table_active, 0x4673/0x4675 = tracked_entity_handle_table (12 entries × 0x0c = 0x90 bytes).


Follow-up: Tracked Bucket System Init/Shutdown

Address Name Notes
000a:5186 tracked_entity_bucket_system_init Allocates a rotating buffer via 0009:3600, lazily creates tracked_entity_bucket_backend_object through 0009:5600 when absent, installs that object into cache_loader_interface, allocates the tracked handle table via 000a:5e00, allocates the 32-entry 0x69ac bucket array via 000d:cca3(0x20), then sets tracked_entity_bucket_system_enabled
000a:538e tracked_entity_bucket_system_init_if_configured Only calls the init routine when config/feature gate 0x89f4 is non-zero
000a:5223 tracked_entity_bucket_system_shutdown Tears down the tracked handle table, frees the 0x69ac bucket array, calls backend-object vtable slot +0x38 with (3, backend_object), clears tracked_entity_bucket_backend_object; called from the wider engine teardown routine at 0004:621b

System globals: 0x45aa = tracked_entity_bucket_system_enabled, 0x45ab/0x45ad = tracked_entity_bucket_backend_object.

Public thin gate wrappers that feed the 0x69ac tracked-entry layer:

  • 0005:3b34 = tracked_entity_bucket_alloc_main_if_enabled
  • 0005:3b53 = tracked_entity_bucket_alloc_aux_if_enabled
  • 0005:3b72 = tracked_entity_bucket_remove_by_entity_and_ref_if_enabled → forwards into 000d:d086 = tracked_entity_bucket_remove_by_entity_and_ref when 0x45aa is set.

Follow-up: Backend Object Constructor

Address Name Notes
0009:5600 cache_backend_object_init Allocates a 0x20-byte object when caller passes null; initializes embedded DOS file-handle state via dos_file_handle_init; seeds internal method-table / state fields at object offsets +0x08, +0x0c, +0x10, +0x14, +0x16, +0x18, and +0x1c; dispatches through the object method table during construction; returns the object pointer cached at 0x45ab/0x45ad

Verified callback roles inside cache_lookup_or_load_entry_by_id:

  • backend vtable +0x34 = size query callback for a cache entry id (used before allocation/eviction)
  • backend vtable +0x0c = load/bind callback that populates the newly allocated slot buffer for the requested id

Follow-up: External Reset Paths

  • The path around 0004:25a9 classifies as an external reset sequence: it calls cache_reset_runtime_state, then tracked_entity_handle_table_clear_and_dispatch, then continues through additional tracked-entry/cache-side refresh helpers (000d:cd22, 000d:44b3, 0006:ae66, 0006:ae00, etc.).
  • The path around 0004:eb80 is a conditional tracked-bucket reset/update sequence: when tracked_entity_bucket_system_enabled is set, it calls tracked_entity_handle_mark_remove_all_if_enabled, then tracked_entity_handle_table_clear_and_dispatch, then cache_compact_arena_blocks, before resuming its outer flow.

Follow-up: Repaired seg004 Reset-Path Function Objects

Address Name Notes
0004:2592 runtime_cache_reset_sequence Calls 0008:7bfe; calls game_mode_init(*(0x27c4)); calls import-resolved site 0004:25a4, now verified from the separately imported ASYLUM.DLL as ordinal 24 = _ASS_StopAllSFX; then resets cache runtime state, clears tracked handles, refreshes tracked-entry/cache helpers. Known caller: 0004:262d inside the tiny wrapper at 0004:2620, which sets byte +0x40 on the object at 0x6828 before invoking the reset sequence.
0004:eb1f entity_dispatch_entry_ctor_0f3a_with_cache_reset Allocates/initializes an entity dispatch entry; stamps entry type 0x0f3a; stores its two word payload fields; runs local setup through embedded helper at 0004:ebf4 (which dispatches entity_dispatch_reset_all(*0x7e22, 0x00f0) and — when the local flag plus global 0x0ee1 allow it — allocates a type 0x0f5e dispatch entry and passes it to entity_pair_sync_b); when tracked_entity_bucket_system_enabled is set, performs the tracked-handle removal / clear / cache-compaction sequence before finalizing through 0009:b1c3 in phase 0.
0004:ea00 entity_dispatch_entry_alloc_type_0f5e Reuses the incoming FAR pointer when non-null; otherwise allocates 0x33 bytes through mem_alloc_far; initializes the entry through entity_dispatch_entry_init; stamps the entry type word at +0x00 to 0x0f5e.

Follow-up: seg082 Allocator Cluster

Address Name Notes
0009:a229 (size-only wrapper) Public size-only wrapper around the seg082 allocator. Lazily initializes the allocator on first use through 0009:bcb9, then calls allocator_try_alloc_from_head_table(size, default_tag, 0xff).
0009:bcb9 (lazy initializer) One-time lazy initializer. Parses an optional -x tuning value from the PSP command line, clamps derived percentage into 0x14..0x50, seeds local seg082 helpers, then sets init flag 0x4096 = 1.
0009:b06b allocator_try_alloc_from_head_table Validates requested size, reserves a temporary work token through 0009:e15f, scans the 0x8724 allocator head table in 0x0c-byte entries via allocator_head_try_alloc_block. On success, commits the result through 0009:e2b4, clears failure flag 0x4098. When a pass does not find a fit, interleaves up to two finalize phases through allocator_phase_finalize_pass(phase) before the final retry.
0009:a336 allocator_head_try_alloc_block Per-head first-fit allocator. Normalizes requested size (rounds odd small requests up, page-aligns large non-page-aligned requests), adds 0x0a node header overhead, enforces minimum of 0x10 bytes. Walks the node chain for one allocator head until it finds a free span large enough. On success, unlinks the chosen free node, either consumes it whole or splits off a remainder when >= 0x10 bytes remain. On failure, returns 0.
0009:a5d1 allocator_head_free_block Per-head free paired with allocator_head_try_alloc_block. Rebuilds the node header from a payload pointer (payload - 0x0a), validates the owner/tag word, reinserts the block into one allocator head, and coalesces with adjacent free neighbors when possible.
0009:b224 allocator_free_block_by_ptr Converts the payload pointer back through local header helpers, scans the 0x8724 head table for the owning range, dispatches to allocator_head_free_block, and aborts if no owning head is found. Known wrappers 0009:a24f and 0009:a27a are small checked entry points into this path.
0009:b1c3 allocator_phase_finalize_pass Accepts phase bytes 0 or 1. Forwards that byte twice to the object rooted at 0x4588 through vtable slot +0x08. Then sweeps the allocator head table at 0x8724 up to the active head count at 0x879c, calling allocator_head_finalize_sweep on each entry.
0009:af87 (free-space probe) Walks the node chain rooted at 0x8724. For each node, accumulates node_size - 9 into a running total and tracks the largest single free block. Used by cache_init and seg013 path at 0004:833b.
0009:a961 (per-head finalize sweep) Walks one 0x8724 head's node chain, skips odd-tagged spans, coalesces or rewrites eligible spans, and updates head/back-pointer links when deferred space needs to be merged back into the chain.

Allocator head table structure:

  • 0x8724 = array of 0x0c-byte allocator heads
  • 0x879c = active head count / table limit
  • Per-node size/value encoding manipulated through 0009:c628 and 0009:c6ae, which read/write a packed 32-bit quantity split across word + byte + byte fields

Follow-up: seg137 Palette and Dispatch-Entry Helper Family

A coherent palette-write and palette-backed dispatch-entry emission family tied to the same runtime-state constructor lane.

Address Name Evidence
000d:85da vga_palette_set_all_black Allocates a 0x100-entry palette buffer filled with zero RGB triplets, writes it to VGA, frees the scratch buffer. (Previously mis-named map_object_set_dirty_flag.)
000d:8653 vga_palette_set_all_white Same shape as black — all three RGB components initialized to 0x3f, then written through vga_palette_write.
000d:86cc vga_palette_set_all_rgb Takes caller-supplied RGB bytes, replicates them across a 0x100-entry palette buffer, writes the result to VGA, frees the scratch palette.
000d:82ea dispatch_entry_create_black_palette_state_active Builds a runtime-state dispatch entry of type 0x051e from a black 0x100-entry palette buffer; first sets g_active_dispatch_entry_farptr[+0x40] = 1.
000d:8a47 dispatch_entry_create_black_palette_state Same as above without marking the active dispatch entry.
000d:83be dispatch_entry_create_grayscale_palette_state_active Reads the current VGA palette, normalizes each triplet by copying the first channel across all three RGB bytes, then builds a runtime-state dispatch entry from that grayscale palette while marking the active dispatch entry.
000d:875d dispatch_entry_create_solid_palette_state_active Validates 0..0x3f RGB inputs, fills a scratch 0x100-entry palette buffer with that solid color, builds the same 0x051e runtime-state dispatch entry, marks the active entry.
000d:88b2 dispatch_entry_create_solid_palette_state Same as above without marking the active dispatch entry.

Additional caller-side comments (not renamed) added on:

  • 000d:84f4 — current-palette dispatch entry paired with a second object of type 0x68bf through entity_pair_sync_b
  • 000d:89c6 — parameterized current-palette runtime-state wrapper with active-state flags

Follow-up: seg138 Caller-Side Dispatch-Entry Emission Helper

FUN_000d_938c (000d:938c-000d:9583) — a real caller-side helper with an evidence-preserving decompiler comment added in Ghidra instead of forcing a speculative rename.

Current verified behavior:

  • When the mode/global gate is not already in the 0x13:0x0008 state and entity byte +0x33 is clear, it allocates a scratch palette buffer, constructs a dispatch entry, sets type 0x051e, and initializes runtime state through entity_dispatch_entry_init_runtime_state with entry kind 0x3c.
  • Later in the same helper it constructs a second dispatch entry from the current palette globals at 0x4e4:0x4e6, again sets type 0x051e, and initializes runtime state with entry kind 0x14 and active-state parameters (1,0,1).
  • Both created entries are polled until their runtime flag word clears bit 0x0002, after which the helper redraws the global sprite path, syncs display-state byte 0x58e from the entity when the global display object exists, calls FUN_0006_16e1, clears g_active_dispatch_entry_farptr[+0x40], and finally dispatches through the input object's vtable slot +0x08.

Follow-up: seg005 Startup/Display Orchestration

Address Name Notes
0004:60c0 startup_display_transition_prepare Startup/display transition prepare step: broad setup calls, reads the live VGA palette, validates a caller-provided object through vtable slot +0x0c, drives the sprite/object lane through 0x4f38, creates the default active dispatch entry through active_dispatch_entry_create_default, programs mouse interrupt state via seg056 INT 33h wrappers, then hands off into startup_display_transition_driver.
0004:1e00 startup_display_transition_driver Non-return startup/display transition driver: raises the shared active-dispatch hold byte around the seg049 watch/controller lane, then clears it before the seg080 redraw and seg126 follow-up path.
000d:7600 active_dispatch_entry_mark_enabled Marks the active dispatch entry enabled
000d:760e active_dispatch_entry_mark_disabled Marks the active dispatch entry disabled
000d:761c active_dispatch_entry_create_default Creates the default active dispatch entry

Current verified caller-side detail:

  • startup_display_transition_prepare now has enough exact instruction evidence to pin the seg108 lane down more tightly. Window 0004:618e..620c calls the 0x4f38 sprite/object helpers in a stable sequence around the shared active-entry creation: seg108 000b:1e39, 000b:2492, and 000b:26bd run before active_dispatch_entry_create_default, and seg108 000b:2706 runs again immediately before the handoff into startup_display_transition_driver.
  • The same seg108 window now shows a local bounded counter/stack contract instead of reuse of the validated caller object. 000b:26bd increments object word +0x196 up to 7 before calling the common local helper at 000b:2592, and 000b:2706 reads one prior slot from +0x186, decrements +0x196, and replays the same helper before startup_display_transition_driver takes over.
  • The seg108 helper pair is now named too: sprite_object_push_state_word (000b:26bd) increments the bounded local stack depth at +0x196, stores the incoming word into the per-object stack at +0x186, refreshes local sprite state through 000b:2592, and replays redraw when the object was already marked dirty; sprite_object_pop_state_word (000b:2706) returns the previous top word, decrements the same bounded depth, and reapplies the new top through that same helper. This makes the 0x4f38 lane read more like a self-contained sprite/object state stack than a reused validated caller object.
  • startup_display_transition_driver now has exact hold-token ordering too. Window 0004:2013..212c raises g_active_dispatch_entry_farptr[+0x40], dispatches the seg049 watch/controller object at 0x2bd8 through vtable slot +0x2c, runs the intervening transition call at 0004:eece, and then clears the same shared hold byte again just before the seg080 redraw pair and seg126 follow-up.
  • Upstream caller tracing now shows that the four 0004:eece call shapes are chosen from one startup switch-parser lane rather than from a named local phase enum. Window 0004:63c1..66fb walks an argv-like table against a local dispatch/jump table, and the 0004:64ff..65c1 case loads globals 0x84a/0x84c/0x84e/0x850 plus scalar 0x856; startup_display_transition_driver later fans those values into the 0004:2049, 0004:20b3, 0004:20c6, and 0004:20fe call variants. The other direct caller anchors at 0004:2657, 000c:8786, and 000c:742c all remain inside the same startup/display presentation-handoff family, so the safest output is still tighter caller-family semantics rather than a new neutral state label.
  • The seg049 and seg108 globals are now better separated by direct decompile evidence rather than only call-window correlation. watch_entity_controller_dispatch_if_present confirms that 0x2bd8 is a real controller object with active vtable dispatch at slots +0x2c and +0x30, while sprite_object_set_flag40_if_present and sprite_object_clear_flag40_if_present show that 0x4f38 is a separate global object lane whose immediate local contract is only bit 0x40 in object word +0x32.
  • The seg127 fade-controller ownership is also one step tighter in the same lane. transition_preentry_setup_resources resets 0x630a at 000c:c855, transition_preentry_step_script now has a verified early gate at 000c:ca25 that yields to the fade controller whenever 0x630a is active, and transition_palette_fade_begin at 000c:cdca explicitly installs palette source/range/step state into 0x630e..0x6316, asserts 0x630a, and kicks one immediate fade tick.
  • Fade direction is now pinned to seg126 script-control bytes rather than the outer seg005 wrappers. Inside transition_preentry_step_script, control byte 0x5e reaches palette_fade_begin_full_down at 000c:cb06, while control byte 0x26 reaches palette_fade_begin_full_up at 000c:cd1a; control byte 0x2a shares the same post-fade bookkeeping path after the full-up call.
  • The upstream producer path for the remaining seg126 control bytes is now tighter too. transition_preentry_setup_resources composes one path from the mutable base at 0x6aa:0x6ac plus local name buffers (0x631c, 0x6335) through the seg072 slash-aware path helper 0009:3600, opens that file through file_handle_alloc_init_and_open, allocates a buffer of the returned size, reads the full payload into 0x6301:0x6303, and seeds 0x62fa/0x62fc/0x62ff/0x6305/0x630a/0x6318 before the loop starts. Current best reading is therefore file-backed transition script/control buffer, not locally synthesized opcodes.
  • The adjacent seg126 selector lane is now classified tightly enough for conservative renames. transition_file_family_select_and_refresh (000c:afa5) keys object field +0x49 through values 0, 1, and 4, composes three sibling filenames from the inherited base 0x6aa:0x6ac plus shared stem 0x621c with suffix buffers 0x6223, 0x622d, and 0x6237, loads the chosen file into object +0x520, and then runs the same redraw/palette/input refresh path. The same helper uses field49==2 as a direct vtable[0x3c] callback branch and field49==-1 as a normalize-back-to-zero state.
  • The local wrappers around that selector now sharpen the caller model without forcing a stronger UI label. transition_file_family_advance_on_anim_tick (000c:b153) increments +0x49 when the polled byte at [param_2+0x14+0xa] is clear and then re-enters the selector, while transition_file_family_input_key_handler (000c:b199) maps Left/Right and n/N into previous/next selector steps, uses e/E plus repeated - to force selector state 4, and otherwise exits through vtable[0x3c].
  • This closes the narrow +0x49 question as a local three-way file-family selector lane, but it still does not justify a stronger UI label for the paired 0x8c5c/0x8c60 renderer presets or the sibling seg127 fade inputs.
  • The remaining transition_preentry_step_script opcodes now have stable local mechanics even though the higher-level text semantics are still open. Control byte 0x21 consumes the next script word into SI and advances 0x62ff by two, which makes it the current baseline/start-position loader for later text draws. Control byte 0x40 renders one null-terminated entry from the same script buffer through renderer object 0x8c5c:0x8c5e, while control byte 0x24 mirrors that behavior through 0x8c60:0x8c62; both paths measure width through the renderer vtable, draw through seg088 000a:30d7, blit through seg080 0009:943a, advance SI by rendered width plus four, and then scan forward to the next opcode byte. Control byte 0x23 sets local completion byte 0x62fe = 1 and returns, so the outer shell exits on the next loop test instead of iterating further.
  • Secondary renderer-factory sampling keeps the 0x8c5c / 0x8c60 split conservative. Other sampled 000a:9748 xrefs use different adjacent preset pairs such as 0x0d/0x0c at 0007:df30/df3f and 0x0c/0x0f at 0008:47c9/4851, while no sampled caller reproduced the exact 0x10/0x11 startup pair outside transition_preentry_setup_resources. That supports keeping these as paired preset text renderers without forcing a title/body or normal/highlight label.
  • The missing seg126 step body at 000c:ca1d still cannot be split out safely because create_function_by_address collides with the existing oversized overlap namespace, so this pass preserved the recovery as a decompiler comment instead of forcing a destructive boundary repair. Current best reading is still that 000c:ca1d..cd34 is the real transition_preentry_step_script body and that 000c:cd35 starts the fade-tick helper.

Follow-up: 0x31a2 Break/Hold Depth and Active Dispatch Ownership

This pass tightened the shared startup/display transition lane enough to preserve the gate semantics directly in Ghidra and to promote the active-dispatch helpers out of an isolated foothold.

Verified 0x31a2 role

  • 0008:a283 increments 0x31a2 while installing one live far-pointer slot record into the seg008 per-index table, and the paired path at 0008:a314 decrements 0x31a2 while clearing that same record.
  • 0005:453a is now commented in Ghidra as a plain getter for the shared 0x31a2 depth word.
  • transition_preentry_run_until_complete_or_abort (000c:c9f4) now decompiles cleanly and confirms the main transition loop runs until either local completion byte 0x62fe becomes non-zero or 0x31a2 becomes positive.
  • The shell around transition_preentry_step_script is now tighter too: 000c:ca11 is the direct second exit test in the outer seg126 loop, so a positive 0x31a2 falls straight into transition_preentry_release_resources even when local completion byte 0x62fe is still clear.
  • 0004:c24d and 000c:e4d8 are now tightened as pure busy-wait edge loops on 0x31a2, while 000c:e546 and 000c:e5c6 are the same break-depth check embedded in local presentation/cleanup loops rather than plain one-shot flag tests.
  • The blocking/waiting readers around 000c:e4d8, 000c:e546, and 000c:e5c6 treat 0x31a2 as an asynchronous break condition rather than a local state bit: they either busy-wait for a positive edge or abort their local presentation loop early when the depth is already positive.
  • Additional caller-side reads at 000d:9304, 000d:b6b1, and 000d:c0ee all use 0x31a2 > 0 to short-circuit or advance local dispatch-entry state, which fits a shared break/hold depth better than a one-shot acknowledge flag. dispatch_entry_kind2_tick_hold_and_maybe_dispatch (000d:92eb) is still the clearest recovered dispatch-entry example: when a kind-0x0002 entry is pending, a positive 0x31a2 lets the helper dispatch vtable slot +0x08 even if the local +0x40 hold byte is still asserted, after which it decrements that local byte. The newly checked 000d:b6b1 reader is narrower: it advances one local state-5 branch only when entity byte +0x78 is set and class/state word +0x16 carries bit 0x4000, then optionally runs the seg092 follow-up when the 0x45aa gate and entity byte +0x74a are both active.

Current best neutral description: 0x31a2 is a shared asynchronous break/hold depth maintained by the seg008 install/remove path and consumed by transition/presentation code as a positive-count modal break condition.

0x6341 to 0x6828 relationship

  • The missing seg126 function object at 000c:c63a is now created and named transition_preentry_setup_resources; its body allocates the paired temporary text-renderer objects at 0x8c5c/0x8c60, draws the preset 0x10 and 0x11 text variants, loads the file-backed buffer into 0x6301:0x6303, and seeds the pre-entry state bytes before the main loop starts.
  • The paired helper transition_preentry_release_resources (000c:c890) still handles teardown, but its late branch at 000c:c958 also constructs the transition-local animation object at DS:0x6341 through animation_ctor_variant_a, then immediately sets g_active_dispatch_entry_farptr[+0x40] = 1 at 000c:c963.
  • active_dispatch_entry_create_default (000d:761c) still owns the canonical g_active_dispatch_entry_farptr installation.
  • dispatch_entry_kind2_tick_hold_and_maybe_dispatch (000d:92eb) now makes that paired readback explicit: when a kind-0x0002 dispatch entry is pending and either entry byte +0x40 is already non-zero or the shared break depth 0x31a2 is positive, it dispatches vtable slot +0x08 and then decrements +0x40.
  • FUN_000d_938c and entity_cleanup_resources_and_dispatch both clear g_active_dispatch_entry_farptr[+0x40] after their palette/presentation handoff work, which ties the active entry to the same transition-owned presentation lane rather than to an isolated constructor helper.
  • entity_cleanup_resources_and_dispatch is now tighter on the caller side too. Its first 0x4588 callback emit at 000d:9d5e is confirmed as the entity +0x12d/+0x12f payload-pair path, while the later emit at 000d:a3b7 uses the separate entity +0x74f/+0x751 pair. Both still sit inside the same palette/watch-controller cleanup body rather than a separate callback-only helper.
  • Caller-role alignment is now tighter across the remaining startup/display cleanup bodies. The mis-split seg126 window 000c:6176/619c -> 000c:6226 constructs only a temporary local animation payload, frees it, dispatches the seg049 watch/controller object at 0x2bd8 through vtable slot +0x2c, and then clears the shared owner byte +0x40; FUN_000d_938c similarly waits on two temporary palette/state entries, redraws, clears the same shared hold byte at 000d:958d, and only then dispatches its caller object through vtable slot +0x08; entity_cleanup_resources_and_dispatch clears g_active_dispatch_entry_farptr[+0x40] at 000d:a1cf only on the branch where entity byte +0x737 is set and no temporary object remains, before falling into the same watch/controller dispatch at 000d:a1ed. That is enough to align them as consumers of one shared presentation hold token around the seg049 lane, but still not enough to justify a single higher-level subsystem rename for FUN_000d_938c or the mis-split seg126 body.

Follow-up: shared owner versus borrowed hold-token model

  • active_dispatch_entry_mark_enabled (000d:7600) and active_dispatch_entry_mark_disabled (000d:760e) are now verified as tiny wrappers that only write the shared owner byte g_active_dispatch_entry_farptr[+0x40] = 1/0; they do not install or replace the owner object.
  • entity_dispatch_entry_init_runtime_state (000d:7e00) now tightens that ownership split further. When it builds a runtime-state dispatch entry, it copies the current shared owner byte at g_active_dispatch_entry_farptr[+0x40] into the new entry's local byte +0x40; if the new entry stays inactive while a shared owner exists, it raises the shared owner's +0x40 byte to 1 instead of replacing the owner pointer.
  • The paired destructor entity_dispatch_entry_release_runtime_state (000d:8078) clears g_active_dispatch_entry_farptr[+0x40] when the runtime-state entry was marked as owner-propagating (+0x41 != 0) or when the entry's local hold byte was never asserted. This matches borrowed hold-state propagation, not separate owner creation.
  • startup_display_transition_driver now provides the clearest caller-side proof in seg005: it raises g_active_dispatch_entry_farptr[+0x40] at 0004:2013 before the seg049 watch/camera path and the 0004:eece transition call, and clears that same byte again at 0004:2128 before the seg080 display update pair, sprite redraw, and seg126 follow-up thunk 000c:82f9.
  • The still-mis-split seg126 window around 000c:6176/619c also fits the same model. It makes one mode-dependent animation_ctor_variant_a call on a temporary local object, frees that temporary object, reloads the palette, dispatches the 0x2bd8 watch/camera controller through vtable slot +0x2c, and later clears g_active_dispatch_entry_farptr[+0x40] at 000c:6226. No canonical owner installation is visible in that body.
  • The thin seg005 wrappers at 0005:3c36 and 0005:3c5b are now confirmed as pure animation_ctor_variant_a preset shims. Combined with the seg126 windows above, current best reading is: DS:0x6341 and the other constructor callsites build transition-local or display-local animation payloads, while 0x6828 remains the shared active-dispatch owner installed elsewhere.

Current best model: g_active_dispatch_entry_farptr is a shared owner installed by active_dispatch_entry_create_default, and the startup/display transition lane mostly borrows and propagates the owner's byte +0x40 as a hold/busy token while palette/runtime-state helpers run. The remaining open problem is the exact state/object label behind the seg049 watch/camera path, the seg108 sprite/object lane, and the cleanup branches that consume the same token.

Current batch: presentation handoff family versus single-owner hypothesis

  • The exact late sequencing now supports one stricter neutral read: these bodies behave like a shared startup/display presentation handoff family, but not like one single owner-object family. In startup_display_transition_prepare, the validated caller object (vtable +0x0c), the seg108 0x4f38 lane, and the seg049 0x2bd8 watch/controller lane stay separated by direct instruction windows rather than collapsing into one reused object path.
  • transition_preentry_setup_resources also tightened the paired renderer role one step further. Window 000c:c659..c6ab allocates renderer presets 0x10 and 0x11 through seg099 000a:9748, stores them at 0x8c5c:0x8c5e and 0x8c60:0x8c62, and immediately draws the same seed text buffer DS:0x631a at (0x0a,0x0a) through both objects. That makes the pair look like two preset text-render variants inside the same temporary presentation lane, not two separate owner objects.
  • The shared hold-token consumers now line up more exactly than before. startup_display_transition_driver raises g_active_dispatch_entry_farptr[+0x40] before the seg049 +0x2c dispatch and clears it again before the redraw/follow-up path; transition_preentry_release_resources tears down the paired renderers and script buffer, and only on its late completion branch builds local DS:0x6341 then raises the same shared byte; the mis-split 000c:6176/619c -> 000c:6226 body frees its temporary local animation object, reloads the palette, dispatches 0x2bd8, and only then clears the shared byte; FUN_000d_938c waits on two temporary palette/state entries, redraws, clears the shared byte, and only then dispatches caller vtable +0x08; entity_cleanup_resources_and_dispatch clears the shared byte only on the late +0x737 cleanup branch immediately before the same 0x2bd8 dispatch.
  • The seg049 controller lane is also slightly tighter locally. watch_entity_controller_create_global (0007:ba00) delegates to watch_entity_controller_create (0007:ba45), which stamps type 0x2c2b, stores the global object at 0x2bd8, and seeds static row DS:0x2be4 into 0x39ca[obj+2]; the common watch_entity_controller_dispatch_if_present wrapper (0007:ba13) then runs both vtable slots +0x2c and +0x30. That still supports a real controller object, but not a strong enough state label to rename the wider family.
  • The seg108 lane is one step tighter in the same pass. sprite_redraw_if_needed (000b:2492) remains the redraw-facing helper, while the newly named sprite_object_push_state_word / sprite_object_pop_state_word pair show that prepare-time use of 0x4f38 is bracketed by a bounded per-object state-word stack at +0x186/+0x196 rather than by reuse of the validated caller object or the seg049 controller.

Current safest naming conclusion from this batch: keep the existing concrete function names, treat FUN_000d_938c and the related seg126/seg138 callers as one shared startup/display presentation handoff family, and defer any stronger single-state or single-owner rename until a caller-side state discriminator appears.

This is enough to treat seg136 as a shared active-dispatch owner/hold-state controller and seg138 as a real cleanup/presentation caller family, even though the final subsystem name is still open.

Current batch: exact edge waits, interleaved handoff, and broader sprite-stack reuse

  • The remaining pure 0x31a2 edge waits are now exact rather than inferred. 0004:c24d is a two-phase wait that first spins while the shared break/hold depth is non-zero and then spins until it becomes positive again before continuing, while 000c:e4d8 is the simpler positive-edge gate that only waits for 0x31a2 > 0 and immediately returns into the local presentation path.
  • FUN_000d_938c is now slightly tighter on sequencing even though it still does not justify a rename. The first scratch-palette runtime-state entry (kind 0x3c) is only built on the branch where global 0x68e6 is not already in the 0x13:0x0008 mode or entity byte +0x33 is clear; after that wait completes, the helper clears the seg049 controller bit and dispatches 0x2bd8 through vtable slot +0x2c, performs one rectangle/display sync, and only then conditionally builds the current-palette runtime-state entry (kind 0x14) before the final redraw, shared hold-byte clear, and caller-object vtable +0x08 dispatch. That keeps the body inside the same presentation-handoff family while making it less plausible as a single-owner constructor.
  • Additional seg108 push/pop callsites now show that the 0x4f38 lane is reused outside the startup prepare shell. Windows 000b:9bb8/9bda and 000b:9c3c/9c6e bracket transient seg101 presentation helpers with the same sprite_object_push_state_word / sprite_object_pop_state_word pair on global sprite object 0x5e82:0x5e84, while 000c:831f, 000c:8845, 000c:8909, and 000c:a05f pop that same state stack during later UI or object-cleanup flows. This strengthens the neutral reading that 0x4f38 is a generic sprite-object state stack, not the validated prepare-time caller object and not the 0x2bd8 watch/controller object.

Current batch: shared hold-token ownership closure

  • The highest-value remaining ownership question in the startup/display lane is now narrow enough to close without a speculative rename. active_dispatch_entry_create_default remains the canonical installer for g_active_dispatch_entry_farptr, while the later seg005/seg126/seg138 bodies only borrow or propagate the shared owner byte +0x40 as a transition/presentation hold token.
  • The set/clear sites now line up as one borrowed-hold family instead of competing owner installs. startup_display_transition_driver raises the shared byte at 0004:2013 before the seg049 controller dispatch and clears it at 0004:2128; transition_preentry_release_resources raises it only on the late completion branch at 000c:c963 after building temporary DS:0x6341; FUN_000d_938c clears it at 000d:958d after both temporary palette/state waits and redraw; entity_cleanup_resources_and_dispatch clears it at 000d:a1cf only on the late cleanup branch immediately before the same seg049 controller dispatch.
  • The seg049 and seg108 lanes stay separate in the exact places that matter for ownership. watch_entity_controller_dispatch_if_present (0007:ba13) confirms 0x2bd8 is a real controller object dispatched through vtable slots +0x2c/+0x30, while sprite_object_clear_flag40_if_present / sprite_object_set_flag40_if_present (000b:2b08 / 000b:2b20) only toggle bit 0x40 in the separate global sprite/object at 0x4f38 + 0x32.
  • The owner/borrow split also remains visible inside the dispatch-entry helpers. entity_dispatch_entry_init_runtime_state copies the shared owner byte into new runtime-state entries and re-raises the owner's +0x40 byte when needed, which matches propagation of borrowed hold state rather than transfer of owner identity.

Current best neutral conclusion from this pass: the shared g_active_dispatch_entry_farptr[+0x40] byte is a startup/display presentation hold token borrowed across the seg049 controller lane and later cleanup/handoff bodies; the seg108 0x4f38 lane is a separate local sprite-object state stack with its own bit-0x40 contract, not the owner of the shared active-dispatch token.

Current batch: seg126 control-stream producer tightening and completed 0x31a2 read classes

  • The higher-level seg126 control-byte producer is now tighter without breaking the conservative file-backed model. transition_preentry_run_until_complete_or_abort (000c:c9f4) still has no data/bytecode arguments and is only reached from the local wrappers at 000c:7427 and 000c:0d0d; both wrappers only stage the surrounding presentation lane before entering the seg126 loop, and neither injects script bytes or a script pointer.
  • transition_preentry_setup_resources (000c:c63a) remains the only verified source of the consumed bytes. It copies the shared mutable base path from 0x6aa:0x6ac, composes local filenames through the slash-aware seg072 helper 0009:3600 using local buffers 0x631c, 0x6323, 0x632c, and 0x6335, opens the resulting file through the seg070 file-handle lane, allocates a buffer of the returned size, and reads the full payload into 0x6301:0x6303 before seeding 0x62fa/0x62fc/0x62ff/0x6305/0x630a/0x6318.
  • Neighboring seg126 code now supports the same selector-path reading. Window 000c:b018..b03d also reloads the same shared base path from 0x6aa:0x6ac and composes a sibling local filename through 0009:3600 using 0x621c/0x6223, which makes the startup/display lane look more like a family of file-selected transition assets than a local script-byte emitter.
  • The upstream 0x6aa:0x6ac question is now narrow enough to close as an earlier inherited path lane rather than an in-scope seg126 producer. Literal-address instruction search still finds no store into 0x6aa or 0x6ac; the seg004 parser window only mutates the first byte of the pointed buffer at 0004:0ccd / 0004:0cd8, while the same parser explicitly installs the sibling root 0x6ae:0x6b0 from parsed input at 0004:0d28..0d2c. Current best read is therefore: 0x6aa:0x6ac already points at a mutable external/default base-path buffer before the seg126 startup/display family begins composing filenames on top of it.
  • The neighboring seg126 helper family sharpens that close further. Window 000c:afa5..b152 keys object field +0x49 through local values 0, 1, and 4, composes three sibling filenames from the shared stem buffer 0x621c plus suffix buffers 0x6223, 0x622d, and 0x6237, loads the selected file into object +0x520 through seg002 0004:0098, then runs the same display/update chain; wrapper 000c:b153..b25f increments or decrements +0x49 on selected event codes and re-enters that same loader. This makes the nearby seg126 lane look like a local three-way transition-asset family selector layered on top of the inherited shared base path.
  • The overlap check does not force repair yet. analyze_function_boundaries still reports 000c:ca1d as a plausible function entry and 000c:cd53 as the next clean function, while the oversized overlap rooted at 000c:db68 still pollutes the namespace. That overlap no longer blocks byte-behavior or read-site classification, but it still blocks clean function recovery for transition_preentry_step_script.
  • The in-scope 0x31a2 readers are now classed cleanly by role. 0004:c24d and 000c:e4d8 are edge waits; 000c:ca11 is the seg126 modal-break exit; 000c:e546, 000c:e5c6, and 000d:c0ee are cleanup-abort exits; 000d:9304 and 000d:b6b1 are deferred dispatch/state-advance gates.
  • Two remaining 0x31a2 reads stay outside that presentation classification set. 0005:453d is only a plain getter wrapper for the shared depth word, and 0008:5149 is a seg008 internal/accounting-side read that adds the current depth to another local count before tripping a >= 0x10 capacity flag.

Current batch: renderer preset contract and seg127 fade-input closure

  • transition_preentry_setup_resources is now exact on the paired renderer setup path. Instruction window 000c:c659..c6ab shows that FUN_000a_9748 is called only with preset ids 0x10 and 0x11, storing the resulting temporary renderer objects at 0x8c5c:0x8c5e and 0x8c60:0x8c62, then immediately drawing the same seed text buffer DS:0x631a at (0x0a,0x0a) through both. This closes the structural question as paired preset text lanes inside one temporary transition presentation path, but still does not justify a stronger title/body or highlight/shadow label.
  • The recovered transition_preentry_step_script body is also slightly tighter on the two text opcodes. 0x40 and 0x24 both measure their string through renderer vtable slot +0x0c, center it inside a 0x280-wide lane, fetch rendered width through slot +0x08, draw through seg088 000a:30d7, blit through seg080 0009:943a, and advance SI by rendered_width + 4; only the selected preset lane differs (0x8c5c for 0x40, 0x8c60 for 0x24).
  • The seg127 fade-controller inputs are now exact rather than only role-level. transition_palette_fade_begin stores palette source at 0x630e:0x6310, start index at 0x6312, count at 0x6314, step at 0x6316, brightness at 0x630d, active flag at 0x630a, and direction/state at 0x630b, then immediately ticks the local fade controller. transition_palette_fade_tick dispatches 0x630b==1 to transition_palette_fade_out_step and 0x630b==2 to transition_palette_fade_in_step.
  • The two default script-selected fade wrappers are now instruction-verified too. palette_fade_begin_full_down at 000c:c616 pushes direction 1, step 4, count 0x80, start 0, and palette buffer DS:0x8c64; palette_fade_begin_full_up at 000c:c600 is the same wrapper with direction 2. Combined with the 0x5e, 0x26, and 0x2a script-byte sites in transition_preentry_step_script, this closes the neighboring seg127 fade-input contract for the startup/display lane.
  • The late presentation-handoff family is now direct-decompile confirmed rather than only caller-window inferred. FUN_000d_938c creates up to two temporary runtime-state palette entries (kind 0x3c, then kind 0x14), waits for them to clear, redraws, clears g_active_dispatch_entry_farptr[+0x40], and only then dispatches caller vtable +0x08; entity_cleanup_resources_and_dispatch shows the same late shared-hold clear on the entity +0x737 branch immediately before the shared 0x2bd8 controller dispatch. That is enough to treat the startup/display major section as materially complete, with only low-impact residual ambiguity around the exact UI label of preset pair 0x10/0x11 and the optional overlap hygiene at 000c:db68.

Follow-up: 0x4588 Object-Role Evidence

The 0x4588 FAR object is a runtime-installed callback/dispatch object that participates in conditional render or presentation-side flow. It has an explicit install, clear, callback, and teardown lifecycle.

Verified lifecycle

  • Install: 000a:4932 and 000a:4936 store the incoming dword into 0x4590 and 0x458c, then 000a:493e stores the incoming FAR object pointer into 0x4588.
  • Clear: 0004:5b8c and 0004:5bbf both clear 0x4588 immediately before the fatal/reporting-style seg091 call through 000a:454d; 0004:5ea7 and 0004:6430 both clear 0x4588 and then immediately run the one-shot teardown path 000a:4a56(1).
  • Teardown: 000a:4a56 checks a once-flag at 0x4595, clears 0x4588 when non-null, optionally performs a vtable +0x0c callback when 0x4590 != 0x458c, then calls vtable slot +0x04 followed by FUN_0009_0d30().
  • Callbacks: 000a:b9e5, 000a:ba66, 000d:9d5e, and 000d:a3b7 all push a two-word value pair followed by the 0x4588 FAR pointer and call vtable slot +0x0c. entity_conditional_render_dispatch calls the same vtable slot with a single literal 0x0101 argument.

Current batch note:

  • runtime_callback_object_init_once, runtime_callback_object_teardown_once, and entity_conditional_render_dispatch now line up even more strongly as a video or presentation-state callback lane rather than a generic allocator client. The object is installed only after BIOS video-state snapshot, teardown emits a final callback only when recorded mode/state changed, and one live caller uses the literal mode-like pair 0x0101 through the same vtable +0x0c slot. That is enough to keep pushing the role toward presentation/video-state callback broker, but still not enough for a fully behavioral subsystem rename.

Payload pairs from payload sync callsites

  • 000d:9d5e → vtable +0x0c payload from object fields +0x12d/+0x12f
  • 000d:a3b7 → vtable +0x0c payload from object fields +0x74f/+0x751
  • 000a:b9e5, 000a:ba66 → emitting only when the candidate two-word pair differs from the current pair, then mirroring that pair through 000b:1e39 using global sprite/object pointer 0x4f38/0x4f3a

Globals

Address Name
0x4588 runtime FAR object pointer (nullable)
0x458c callback sync field (compared against 0x4590 in teardown)
0x4590 paired sync field
0x4594/0x4595 state flags
0x45a6 clock/cookie global used by assert_buffer_valid
0x39ca dispatch callback-table pointer
0x6828 g_active_dispatch_entry_farptr

Follow-up: VM Owner/Resource Loader and Owner-Loaded Class Validation

The next ScummVM-guided validation step now confirms that the sampled owner-loaded EUSECODE classes are compatible with the ScummVM indexing model even though one header detail remains open.

Sampled class-record findings

  • Using the extracted chunks plus the live raw path 000d:44df -> 000d:4c99 -> 000d:7000, the large chunk at table offset 0x88 behaves as object 1.
  • For representative class bodies, deriving object_index = (table_offset - 0x80) / 8, then class_id = object_index - 2, and then reading object 1 at 4 + 13 * class_id yields the expected names: EVENT, NPCTRIG, SURCAMNS, JELYHACK, REE_BOOT, SURCAMEW, and SFXTRIG.
  • This is the first direct local confirmation that the owner-loaded records match the ScummVM object 1 name-table plus classid + 2 body lookup at the indexing level.

Header and event-table shape

  • The loader-side count field is now tighter too. The first dword in the sampled owner-loaded class header is not the total slot count; 000d:5066 uses it as the extra-slot count beyond a fixed 0x20 base table, which is why the cached table allocation is extra_count * 6 + 0xc0 and the refcount array is extra_count * 2 + 0x40.
  • That reading matches the extracted class-family shapes exactly: EVENT keeps first dword 0x00000000, NPCTRIG moves to 0x00000001, and ROLL_NS to 0x00000002, while the already-validated owner-loaded event counts remain 0x20, 0x21, and 0x23 respectively.
  • The sampled class records do contain a stable 4-byte header field at bytes 8..11.
  • The observed values are small boundaries: 0x00d4, 0x00da, and 0x00e6 in the current sample set.
  • Treating that dword directly as the first post-event-table offset makes the layout line up cleanly: (dword_at_8 - 20) / 6 yields valid tables of 32, 33, or 35 slots before inline payload/name data begins.
  • The region at class + 0x14 is therefore now directly confirmed as repeated 6-byte slots with u16 unknown_word + u32 code_or_payload_field layout.
  • Representative low-slot examples are JELYHACK slot 1 = {word=0x002a, dword=0x00000001}, SURCAMNS slot 1 = {word=0x0051, dword=0x000000d2}, SURCAMEW slot 1 = {word=0x00f7, dword=0x000000d2}, EVENT slot 10 = {word=0x1fd6, dword=0x00000001}, and REE_BOOT slots 10/15/16 = {0x034b,1}, {0x025c,0x034c}, {0x003b,0x05a8}.
  • The runtime-side selector arithmetic is now exact as well: the owner-resource callbacks operate on class_id + 2, which matches the extracted object_index column directly. EVENT therefore lands on child 0x363 from class id 0x361, and NPCTRIG on child 0x365 from class id 0x363.
  • The leading event word is still not decoded semantically.

What remains open

  • Scanning with the previously noted ScummVM-style (base_offset + 19) / 6 interpretation overruns into inline payload/name bytes on these owner-loaded records, so the local sample set does not support that exact event-count formula as written.
  • The best current arithmetic fit is now tighter: ScummVM's decremented base_offset is also used as the live code-stream base in uc_machine.cpp, so the local owner-loaded records fit best if bytes 8..11 are the first code-byte offset and event-count derivation is (base_offset - 19) / 6, which is exactly equivalent here to (raw_u32_at_8_11 - 20) / 6.
  • Current 000d loader evidence does not point to a header rewrite before VM consumption. entity_vm_runtime_init_from_path_if_configured (000d:44df) only builds the external path and creates the runtime, entity_vm_runtime_create (000d:4c99) only installs the helper returned by 000d:7000, entity_vm_runtime_owner_resource_create (000d:7000) only allocates the child owner table and fills it through helper vtable +0x0c, and entity_vm_context_create_from_slot_index (000d:46ec) directly reads slot-backed source data from that owner table. No local step is yet verified as rewriting the sampled class headers.
  • The slot-value miss path is now exact enough to align against the extractor rather than only against motifs. entity_vm_slot_load_value (000d:51fd) does not build the returned workspace out of owner-row fields or late interpreter scratch: on a miss it uses 000d:5066 plus the same owner-resource wrapper 000d:714c to read a 0x14-byte class header, then a cached 6 * (0x20 + extra_count) subentry table, and finally the selected subentry's byte range straight into a newly allocated value-object buffer at +0x0a/+0x0c.
  • The final body read at 000d:53b4 now matches the extracted row arithmetic exactly. The 6-byte row contributes word body_len plus dword raw_code_offset, the class header contributes dword code_base, and the reader fetches body_len bytes from code_base + raw_code_offset - 1 through code_base + raw_code_offset + body_len - 2.
  • That gives a direct owner-loaded fit for the two surviving NPCTRIG bodies. For class NPCTRIG (class_id = 0x363, object_index = 0x365), slot 0x0a uses {len = 0x0175, raw_code_offset = 0x00000001, code_base = 0x00da} and therefore materializes range 0x00da..0x024e (373 bytes), while slot 0x20 uses {len = 0x0159, raw_code_offset = 0x00000176, code_base = 0x00da} and therefore materializes range 0x024f..0x03a7 (345 bytes). EVENT slot 0x0a fits the same runtime arithmetic with {len = 0x1fd6, raw_code_offset = 0x00000001, code_base = 0x00d4} -> 0x00d4..0x20a9.
  • Because 000d:5066/51fd/53b4 now line up with the extracted class headers and event rows byte-for-byte, the remaining immortality blocker is no longer header math or slot-number translation. The open step is upstream class selection into this now-verified loader path: whether the live slot 0x0a request really names NPCTRIG, EVENT, or another descriptor family sharing the same owner-loaded format.
  • entity_vm_runtime_owner_resource_create (000d:7000) still does not expose a direct binary-side class-name lookup or explicit classid + 2 arithmetic. What it does expose is an indexed file-set loader contract: helper-owned count at +0x14, far-pointer table at +0x10, paired per-entry word table at +0x18, vtable +0x04 size query, and vtable +0x0c materialization of the 0x0d-stride owner records later consumed by entity_vm_context_create_from_slot_index. The current pass also makes the helper shape slightly more concrete: the two raw seg070 windows at 0009:67b6 and 0009:6916 are twin per-entry path/read loops with distinct format strings (DS:3f2d and DS:3f40) but the same +0x10/+0x18 indexing and file open/read/close lane, which is better evidence for a multi-table or multi-phase external loader than for direct in-memory descriptor iteration.
  • The signed slot-offset lane used by the still-xref-dark wrappers 0005:2c35 / 0005:2c68 is also no longer confined to entity_vm_context_create_from_slot_index (000d:46ec). Ghidra now reflects that contract in the conservative wrapper names entity_vm_context_try_create_mask_0400_slot0a_with_offset and entity_vm_context_try_create_mask_0800_slot0b_with_offset. Inside entity_vm_runtime_create, the pre-entry body at 000d:4c25..4c90 reloads object fields +0x32/+0x34 through entity_vm_slot_load_value_plus_offset (000d:5572), stores the reconstructed DX:AX pair into object fields +0x10c/+0x10e, and also caches the owner-source far pointer at +0x117/+0x119. The paired save path at 000d:49ec is narrower than it first looked: it serializes only the low word at +0x10c through seg070 0009:2034, while the high word is recomputed on load from the fresh entity_vm_slot_load_value() result plus the saved additive word.
  • Current disassembly closes the exact low-slot wrapper contracts too. 0005:2c35 sign-extends caller word [BP+0x0a], then calls entity_vm_context_try_create_masked_for_entity with slot 0x0a and packed mask 0x00000400; 0005:2c68 is the same signed-additive shim for slot 0x0b and packed mask 0x00000800. Neither wrapper has a recovered outward code/data xref yet, so the best current provenance remains extra-word masked materializer family member, not a gameplay event label.
  • The newly recovered post-load consumers of +0x10c/+0x10e are weak and do not behave like a recovered event-dispatch selector. Predicate FUN_0001_a772 returns true only when the pair is exactly 0000:0001, while normalization block FUN_0002_1860 checks segment == 0 and clamps offset < 0x0080 up to 0x0080. No recovered downstream comparison or dispatch branch matches the five verified NPCTRIG slot 0x0a clause starts (0x0064/0x0093/0x00c2/0x00f1/0x0120) or backward targets (0x001f/0x004e/0x007d/0x00ac/0x00db); if anything, the 0x0080 floor cuts across that family instead of confirming it.
  • The masked-create hub in front of that lane is now explicit too. Window 000d:463a..46e8 maps one gameplay entity through entity_vm_slot_index_from_entity, tests the owner/resource table row mask at 0x6611 -> +0x1315/+0x1317 -> (+0x10/+0x12) + 0x0d*slot, and only then calls entity_vm_context_create_from_slot_index. That matters because the offset-specialized wrappers 0005:2c35 / 0005:2c68 are now instruction-verified as nothing more than sign-extended extra-word shims over this generic masked-context hub, rather than separate selector logic.
  • The upstream slot selector is now exact enough to rule out one remaining binary-side shortcut. entity_vm_slot_index_from_entity (000d:45c5) does not expose a class-family choice like NPCTRIG versus EVENT; it only chooses one of three generic category spans before the owner row is consulted: (a) entity ids 1..255 with class-word bit 0x0002 clear map to entity_id + base_0x8c7e, (b) class-nibble 4 objects map to class_byte_0x7e05 + base_0x8c80, and (c) everything else maps to type_word_0x7df9 + base_0x8c7c.
  • The runtime init path now shows where those bases come from too. After entity_vm_runtime_create succeeds, entity_vm_runtime_init_from_path_if_configured (000d:44df) seeds 0x8c7c/0x8c7e/0x8c80/0x8c82 as cumulative category bases by looping over four word counts at 0x6608..0x660e. Because the compiled side only sees those category-base spans and the later owner-row mask words, it still does not reveal a direct descriptor-class discriminator before the slot body is loaded.
  • One direct non-hub consumer reinforces that read. FUN_0005_295f is the only currently recovered caller of entity_vm_slot_index_from_entity outside the masked hub; it recomputes the same slot index, directly tests owner-row bit 0x0040, and then branches into gameplay handling before optionally calling entity_vm_context_try_create_masked_for_entity with mask 0x0040:0x0006. Together with the still-empty xref results for 0005:2c35 and the stable 0005:2c35..2c57 function boundary, the safest current interpretation is that these owner-row words are generic capability masks, not explicit NPCTRIG / EVENT family tags.
  • The next immortality pass separates that owner-row path from the live control-stream path even more sharply. Inside entity_vm_context_create_from_slot_index (000d:46ec), the owner-table row still feeds only the preserved 0x39ca[slot] mirror, while the actual +0xd6/+0xd8 control stream handed to entity_vm_context_setup comes from entity_vm_slot_load_value_plus_offset and the caller-supplied setup/tail pointers come from the current VM frame record. That makes the immediate builder for the 000d:21ed lane slot-backed decoded stream plus frame-local replay, not owner-row decode.
  • That is the current hard wall for the immortality frontier. The strongest verified answer remains that NPCTRIG slot 0x0a is the best upstream descriptor-side fit and EVENT slot 0x0a remains the generic-hub baseline, but the binary selector path now bottoms out at category spans plus row-capability bits rather than at a provable class-family discriminator.
  • The open descriptor question therefore moves one step earlier again. Current 000d loader/runtime evidence still supports a descriptor-derived upstream workspace, but not a direct owner-row-to-opcode path for the immortality trigger. The closest verified compiled-side seeding now happens later inside the hidden dispatcher at 000c:fa2f, where immediate literal cases can push byte/word/dword payloads straight onto the caller stream before the frame replay family re-materializes them into the child frame.
  • The seg070 twin-file-family helper is now tighter at the buffer/schema level as well. The paired loops at 0009:67b6 and 0009:6916 do not reuse one ambiguous scratch object: each loop performs its own size query/allocation sequence, builds paths from the same +0x10/+0x18/+0x14 table trio with its own format string (DS:3f2d versus DS:3f40), feeds a dedicated temporary far buffer through the shared file_handle_alloc_init_and_open / dos_file_seek / dos_file_close trailer, and then frees that loop-local buffer before returning. Current safest read is therefore two distinct temporary file-family materialization passes inside one owner-resource helper, not one callback shard reused for both families.
  • Additional 0x39ca consumers are now classified more cleanly. Beyond the already-known static seeds at 000d:7299 -> DS:67f2 and 000d:761c -> DS:6872, the constructor-like windows at 000d:929a and 000d:963c seed rows DS:68ec and DS:68f5 respectively before enabling local timer/dispatch behavior. Those writes behave like dispatch-entry-local static seed rows, not owner-table mirrors. Separately, FUN_000d_938c reads temporary dispatch-entry fields +0x32/+0x34 at 000d:9449..9468 and 000d:9547..9566 only as a wait/poll condition on the scratch-palette (kind 0x3c) and current-palette (kind 0x14) entries it creates, which further separates active dispatch-entry state from the owner-backed 0x39ca[slot] = {source_off, source_seg} rows written by 000d:46ec.
  • Safe event-label correlation remains intentionally narrow after this pass. The sampled low slot ids are now concrete, but none of them yet have a verified binary-side behavior match strong enough to promote a ScummVM label like look, use, or cachein.

Current batch: higher-slot masked wrapper ladder (0x10..0x14)

  • The gameplay-side masked-wrapper island now extends one verified step past the older 0x0f frontier. Raw call setup around 0005:3115..322d shows five higher-slot entries feeding entity_vm_context_try_create_masked_for_entity with slot ids 0x10, 0x11, 0x12, 0x13, and 0x14.
  • The slot 0x10 lane is not yet a clean standalone function object, but the containing body at 0005:3115..3129 is exact enough to classify its call shape: it pushes zero extra word, slot 0x10, packed mask 0x00010000, and the live entity pointer before the far call to 000d:463a. The preceding guard at 0005:30f2..3113 restricts that path to one class-nibble-4 lane.
  • Four neighboring helpers are now renamed directly in Ghidra from stable function objects:
    • 0005:313e = entity_vm_context_try_create_mask_00020000_slot11_with_offset
    • 0005:3171 = entity_vm_context_try_create_mask_00040000_slot12
    • 0005:31da = entity_vm_context_try_create_mask_00080000_slot13_with_offset_if_valid_entity
    • 0005:31a0 = entity_vm_context_try_create_mask_00100000_slot14_with_offset
  • Their payload shapes are now exact from disassembly, not only inferred from decompile:
    • slot 0x11 pushes one caller-supplied extra word (MOVZX EAX,[BP+0xa] ; PUSH EAX)
    • slot 0x12 pushes a fixed zero extra word
    • slot 0x13 pushes one sign-extended caller word after the same 0005:2686 / 0005:ffed entity-validity gate used by the older slot-0x01 helper
    • slot 0x14 pushes one caller-supplied extra word
  • This widens the verified owner-slot taxonomy in a USECODE-relevant way: the binary is no longer only distinguishing compact low-slot wrappers like 0x0a/0x0b; it also separates a higher-slot family with mixed no extra word versus signed extra word call contracts.
  • The first outward callers in this higher-slot family are now explicit too. entity_vm_context_try_create_mask_00040000_slot12 (0005:3171) is called at 0005:1776 and 0005:1945; both callsites are currently trapped in non-function windows, but they are real direct edges into the slot-0x12 zero-extra-word lane. By contrast, current MCP xrefs still show no direct outward callers for the slot 0x11, 0x13, or 0x14 wrappers and still none for the dark slot 0x0a / 0x0b pair.
  • The persisted-context side of the same lane is now tighter at the field level. entity_vm_context_save (000d:498f) serializes +0x11f, +0x121, the derived low word at +0x10c, the additive word at +0x34, and the 0x80-byte local buffer at +0x36/+0x38; entity_vm_context_load (000d:4a78) rebuilds the frame pointers, reloads the saved low word as the additive argument to entity_vm_slot_load_value_plus_offset, restores +0x10c/+0x10e, and refreshes the owner-linked source pair at +0x117/+0x119. That strengthens the current read that persistence preserves (slot, additive_word, derived_low_word) after selector choice, not the upstream class-family selector itself.
  • The external event-name correlation can now be tightened slightly but still stays hint-level only:
    • slot 0x12 having no extra word is compatible with the external justMoved() zero-argument event label
    • slot 0x13 carrying one extra word is compatible with Pentagram's AvatarStoleSomething(uword) signature
    • slot 0x11 carrying one extra word is compatible with Pentagram's placeholder func11(sint16) signature and with ScummVM's unresolved cast-side slot only at the broad one scalar argument level
    • slot 0x14 currently does not fit Pentagram's older zero-argument animGetHit() signature, so that ordinal should remain slot-numbered on the binary side for now
  • Operational consequence for the current VM lane: there is now stronger binary evidence that the masked-context family is organized around slot ordinals with distinct payload shapes, not only around one low-slot trigger subset. That helps the current round-trip IR because it justifies keeping higher ordinals as slot-stable records with payload-shape metadata even when their event labels remain external hints.
  • The sequencer-side consumer model is also now preserved directly in Ghidra. Address 000d:22bc carries a decompiler comment recording it as a sequencer-internal matrix stage: it reads two signed metadata bytes from +0xd6/+0xd8, consumes caller-stream words as entity/link ids, repeatedly calls 0008:7d27, and only pushes back words without bit 0x0400 before jumping to entity_vm_opcode_finish.

Conservative parser rule from this batch

  • For current owner-loaded/raw EUSECODE work, keep bytes 8..11 raw and derive event count only with (raw_u32_at_8_11 - 20) / 6 when divisibility and object-size bounds checks succeed.
  • Keep the decremented code_base_minus_one = raw_u32_at_8_11 - 1 as a separate code-addressing field rather than collapsing it into the event-count rule.
  • Preserve the 6-byte event rows and their leading word verbatim until the event-entry word semantics are verified.