- Introduced README.md for the PyGhidra Crusader Toolkit, detailing setup and usage instructions. - Added bootstrap_env.ps1 script to create and refresh the Python virtual environment with necessary packages. - Implemented _tmp_patch_hidden_cheat_menu.py and _tmp_patch_hidden_cheat_menu_deferred.py scripts for patching specific memory addresses in Ghidra.
63 KiB
Crusader: No Remorse — NE Segment 1 Game Logic
This file covers the standalone analysis of NE Segment 1 (seg001_code_off_37600_len_8400.bin), imported as a raw binary at base 0x0000, language x86:LE:16:Protected Mode. All 35+ identified functions have been renamed and annotated in Ghidra.
For current project work, treat this file as a verified evidence source to be cross-referenced into the live CRUSADER.EXE session. When a claim here is used in the NE database or notes, keep the NE address and the older segment/raw address linked together where practical.
Cursor Subsystem (0x0060–0x0d5f)
| Address | Name | Description |
|---|---|---|
0x0060 |
cursor_update_hover |
Hover update: if mouse active & entity set, calls cursor_set_target |
0x00e9 |
cursor_set_target |
Positions cursor on entity, updates sprite + direction visual |
0x0322 |
cursor_shutdown |
Frees cursor resources, resets state |
0x0398 |
cursor_animation_update |
Angle-based cursor rotation (0x27d4, 0-359 → 0x168=360). Sprite at 0x27d6 |
0x050f |
cursor_draw_tick |
Per-frame cursor draw (calls cursor_animation_update if dirty) |
0x0c24 |
action_key_valid |
Returns 1 if action code (param_1) is a valid game action key |
0x0d5f |
cursor_direction_input |
Arrow-key input: rotates cursor angle, updates direction sprite |
Input Handling
| Address | Name | Description |
|---|---|---|
0x0526 |
input_keyboard_handler |
Key dispatch: 0x01=LMB, 0x0D/0x0C=scroll, 0x2C=save, 0x44=load |
Cursor State Data (at DS:0x27xx)
| Address | Field | Meaning |
|---|---|---|
0x27c4 |
cursor_sel1 | Selection counter 1 |
0x27c6 |
cursor_sel2 | Selection counter 2 |
0x27c8 |
current_entity | Handle to currently targeted entity |
0x27ca–0x27ce |
cursor_state | Cursor interaction state bytes |
0x27d0 |
cursor_entity_type | Current entity type index |
0x27d2 |
z_offset | Z-height offset for terrain adjustment |
0x27d4 |
cursor_angle | Rotation angle (0–359) |
0x27d6 |
cursor_sprite | Sprite handle for cursor visual |
0x27d8 |
cursor_dirty | Set when cursor needs redraw |
0x27d9 |
cursor_active | Master cursor enabled flag |
0x27da |
cursor_no_turn | Flag disabling cursor rotation |
0x27ed |
difficulty | Enemy accuracy divisor (used in projectile_init_vector) |
0x27fd |
hard_mode | Two-step mode (combat vs. explore) |
0x27fe |
move_mode | Movement phase flag |
0x27ff |
mouse_active | Mouse/input system active |
0x2800–0x2811 |
various | UI state: active sprite, facing byte, cur entity handle |
0x283f/0x2841 |
menu_obj_ptr | Active menu/dialog object far pointer |
0x2844 |
in_save | In-progress save game flag |
0x290e |
entity_count | Number of active entities |
0x2910–0x2947 |
snap_type_ids[10] | Entity types that snap-to-ground in snap_entity_to_ground |
Input / Action Dispatch
| Address | Name | Description |
|---|---|---|
0x2420 |
entity_command_dispatch |
Dispatches player commands to target entity; reads 0x27d0, 0x2de4, sends events 0x14/0xf, handles state machine 0x27ca/0x27cd/0x27ce |
0x279a |
cheat_code_check |
Checks input record byte+1 against five bytes starting at 0x2833; toggles 0x844/0x6045, emits helper/event code 0x103 |
Menu / Event Callbacks
| Address | Name | Description |
|---|---|---|
0x2e53 |
cursor_event_notify_a |
Vtable thunk: forwards event to 0x27ca area handler |
0x2e96 |
cursor_event_notify_b |
Vtable thunk: forwards event to 0x27ca area handler (alt path) |
0x2ed9 |
menu_event_notify_a |
Vtable thunk: forwards event to 0x2843 (near menu object) |
0x2f0c |
menu_event_notify_b |
Vtable thunk: forwards event to 0x2843 (alt path) |
0x2ff3 |
stub_noop_2ff3 |
Empty stub, noop |
0x2ff8 |
entity_collision_callback_a |
Calls touch handler then func(entity+0x1e, seg, 2); opt: extra func if param_3&1 |
0x3046 |
set_active_menu |
Writes param_1/param_2 to 0x283f/0x2841 (active menu far pointer) |
0x3058 |
entity_collision_callback_b |
Same as entity_collision_callback_a (second vtable entry) |
Entity System (0x2401–0x5a50)
| Address | Name | Description |
|---|---|---|
0x2401 |
clear_cursor_selection |
Zeros 0x27c4/0x27c6 (selection counters) |
0x2899 |
cursor_switch_target_entity |
Switches cursor target: unloads old entity, loads new, re-registers |
0x29d8 |
get_z_offset |
Returns func() + *(0x27d2) = adjusted Z/height |
0x2a09 |
is_player_in_range |
Checks if entity is at player (0x2de4) X/Y +/-0xf0 range |
0x2a46 |
entity_ai_update_loop |
Loops entities 2–255, checks visibility, triggers fire/move |
0x2c36 |
ui_update_callback |
Calls cursor_state_clear then vtable[2] on menu object |
0x2c6b |
cursor_state_clear |
Clears cursor state bytes 0x27ca–0x27ce, clears entity flag bit1 |
0x2c92 |
dialog_spawn |
Allocates dialog object, vtable=0x28b5, registers callback at 0x39ca |
0x2d47 |
entity_pick_handler |
Handles entity selection or save-game trigger (type 0x38d) |
0x2df9 |
clear_active_menu |
Zeros 0x283f/0x2841 (active menu far pointer) |
0x2e18 |
game_mode_init |
Initializes game mode state, resets sprite/cursor/menu state |
0x2f3f |
entity_table_set_sprite |
Reads 0x7df9+slot2; writes entity type table 0x7e1e[slot0x79+0x0d]=param_2, +0x10=0 |
0x3c97 |
snap_entity_to_ground |
If entity type in snap_type_ids[10], resets Z to 0xf0 and adjusts XY |
0x3d6e |
spawn_entity_checked |
Spawns entity with explosion pool limit check (0x84c0, 0x84c2) |
0x3f2f |
entity_spawn |
Allocates entity, vtable=0x29aa/0x39ca, positions it |
0x40d4 |
entity_remove |
Removes entity: destroys sprites, clears 0x2802/0x2804 if needed |
0x4172 |
entity_animation_frame_update |
Advances/retreats anim frame ([+0x1d]) toward target [+0x1c/0x1b] based on quality |
0x42f8 |
stub_noop_42f8 |
Empty stub, noop |
0x42fd |
entity_registry_decrement |
Calls cleanup func then decrements entity count at 0x290e |
0x4314 |
entity_sprite_move_delta |
Updates shot sprite handle (entity+0x3f) position by adding delta params |
0x4552 |
entity_set_position |
Sets entity+0x3e (type_handle), world_x/y (entity+0x45/47), base_x/y (entity+0x4f/51) |
0x452b |
shot_set_spawn_pos |
Calls entity_set_position then sets entity+0xbe = param_3 (extra spawn field) |
0x4591 |
entity_try_place |
entity_set_position with validation — position only set if placement succeeds |
0x5092 |
entity_deactivate |
Calls vtable[2] to deactivate, or finds in registry and removes |
0x5a50 |
entity_list_contains |
Checks if entity ptr exists in active entity list at 0x294c |
0x5b05 |
stub_noop_5b05 |
Empty stub, noop |
Entity Object Layout (NE Segment 1 entities)
| Offset | Field | Meaning |
|---|---|---|
+0x00 |
vtable_ptr | Vtable pointer (0x29aa for generic, 0x2a57 for debris) |
+0x02 |
slot_index | Entity slot index (used for registry at 0x39ca) |
+0x04 |
entity_type | Entity type ID |
+0x19/+0x1a |
flags | Entity flags (bit0=debris, bit1=cleared by cursor_state_clear, bit6=active, bit8=valid) |
+0x1b |
vel_x | X velocity (clamped ±0x20) |
+0x1c |
vel_y | Y velocity (clamped ±0x20) |
+0x1d |
vel_z | Z velocity (clamped ±0x10) |
+0x1e |
fire_handle | Weapon/fire handle |
+0x1f |
is_enemy | 1 if entity is an enemy type |
+0x20/+0x21 |
pos_frac_x/y | Fractional position (sub-tile) for movement |
+0x22 |
pos_frac_z | Fractional Z |
+0x36 |
weapon_type | Active weapon type ID |
+0x38 |
facing | Current facing direction (0–15) |
+0x3c |
sprite_handle | Sprite for this entity |
+0x3f |
shot_sprite | Sprite handle for active projectile (0xFFFF = none) |
+0x45/+0x47/+0x49 |
world_x/y/z | Current world position (integer) |
+0x4f/+0x51/+0x53 |
base_x/y/z | Base/spawn position |
+0x54/+0x56/+0x58 |
prev_x/y/z | Previous frame position |
+0x59 |
attack_active | Attack in progress flag |
+0x5a |
at_target | Reached target flag |
+0x5e–+0x65 |
delta_x/y/z/high | Per-step movement deltas (fixed point) |
+0x66/+0x68 |
step_active | Stepping active (1=yes, 0=off) |
+0x6a/+0x6c |
weapon_slot/dist | Weapon slot and total travel distance |
+0x6e |
delta_z | Alt Z delta |
+0x70 |
projectile_type | Projectile class (2/0xD=splash, 3=spread, 5=homing, 0xE=chain) |
+0x72/+0x74/+0x76 |
target_x/y/z | Target position with deviation |
+0x77 |
target_entity | Target entity handle |
+0x79 |
secondary_pos | Secondary position struct pointer |
+0xad |
owner_entity | Owning entity handle |
+0xaf |
shot_owner_flags | Shot owner (entity/player) |
+0xb1 |
bounce_count | Bounce counter (used with homing, type 5) |
+0xb3 |
has_bounce | Has bounce trajectory active |
+0xbd |
actor_type | Actor type byte (used for direction table lookups) |
Shot Entity Lifecycle (0x435e–0x44a9)
| Address | Name | Description |
|---|---|---|
0x435e |
shot_entity_alloc |
Alloc/init shot entity: vtable=0x297e, registry vtable=0x2969, zeros all state, copies player pos to +0xb5/b7 |
0x44a9 |
shot_entity_free |
Cleans up shot entity: frees sprite at +0x3c if valid (set to 0xFFFF), clears callbacks; optional full free if flag&1 |
Projectile / Combat (0x4659–0x5a99)
| Address | Name | Description |
|---|---|---|
0x4659 |
projectile_init_vector |
Sets up shot trajectory: target XY±deviation, step rate from weapon table at 0x2536 |
0x4a91 |
entity_fire_weapon |
Fires weapon from entity using 0x129b/0x12ac direction offset tables |
0x4b18 |
fire_weapon_from_cursor |
Gets cursor angle sprites, fires projectile at cursor target |
0x4b78 |
projectile_check_hit |
Hit test: if entity_type==0 uses bbox+0x79; else full 3D range; copies +0xa0→+0x77 (hit entity) |
0x4c2e |
projectile_step_update |
Advances projectile one step; type 3 spawns sub-shots via spawn_entity_checked |
0x4d28 |
projectile_trace_ray |
Interpolated path trace: divides distance/0x10 into steps, collision checks each step; on hit calls projectile_apply_hit + entity_deactivate |
0x51ad |
projectile_update_tick |
Full projectile tick: move, check reach target, bounce, call projectile_check_hit |
0x5a99 |
projectile_apply_hit |
Applies hit effects: if impacted obj byte+6 non-zero, calls damage func with weapon_slot/type/target/owner |
Weapon Type Table (0x2536)
- Each entry is 0x11 bytes (17), accessed as
weapon_type * 0x11 [0]= step divisor for distance calculation[0x19]= max range threshold (used in projectile_update_tick)
Direction Tables (0x129b / 0x12ac)
- Indexed by facing (0–15): dx offsets at 0x129b, dy offsets at 0x12ac
- Values are multiplied by distance (e.g.
*0x500) for projectile spawn offsets
Collision Detection (0x60c1–0x621e)
| Address | Name | Description |
|---|---|---|
0x60c1 |
aabb_overlaps_3d |
3D AABB overlap test — box layout [xmin,ymin,zmin,,,xmax,,ymax,,zmax] |
0x621e |
bbox_translate |
Translates a 3D bounding box by (dx, dy, dz) — both min and max points |
Enemy AI / Spawning (0x6aed–0x6d21)
| Address | Name | Description |
|---|---|---|
0x6aed |
map_find_spawn_point |
Finds map tile matching entity conditions; returns packed XYZ tile coords |
0x6bfc |
actor_find_in_view |
Finds actor visible in current view frustum (temp data at 0x7eca) |
0x6ce9 |
enemy_spawn_with_target |
Wrapper: spawns enemy with player as target (param5=1) |
0x6d05 |
enemy_spawn_no_target |
Wrapper: spawns enemy without targeting player (param5=0) |
0x6d21 |
enemy_spawn_at_position |
Full enemy spawn: activates entity, assigns velocity from direction table (0x2a00/4/A) |
Player / HUD
| Address | Name | Description |
|---|---|---|
0x50ee |
player_position_update |
Updates player position from direction data; clamps to screen bounds |
0x6ff7 |
player_health_update_and_effect |
Encodes player HP into RGB bitfields at 0x7e46+0x1bec, spawns effect |
Destruction / Death (0x7490–0x75ff)
| Address | Name | Description |
|---|---|---|
0x7490 |
debris_spawn |
Spawns debris/fragment: vtable=0x2a57/0x2a1a, velocity, facing, linked list |
0x75ff |
entity_die |
Death handler: spawns 1–4 debris objects, picks best explosion direction |
Entity Type Constants (weapon_type/entity class)
| Value | Entity Class |
|---|---|
0x17 |
Robot/mech type A |
0x18 |
Robot/mech type B |
0x1 through 0x3c |
Various entity/weapon types |
0x3d |
Robot/mech type C |
0x3e |
Robot/mech type D |
0x2f5–0x2f7 |
Special movement entity |
0x595/0x597 |
Platform/elevator entities |
0x31c/0x322–0x327 |
Explosive/effect entities |
0x38d |
Save game trigger entity |
0x426 |
Spark/scatter sub-shot |
0x59a |
Player cursor/select indicator |
Entity Data Table at 0x7e1e
- Stride:
0x79bytes (121 bytes per entry) - Indexed by entity type (integer) or entity slot
+0x59offset = class-detail flags byte (entity_class_get_flag8returns bit0x08; other callers also clear bit0x10here during at-target facing updates)+0x5aoffset = flags byte (bit4 = special entity flag, bit3 = armor/shield flag)+0x68= targeting flag
Map / Resource Tables
| Address | Content |
|---|---|
0x2833 |
Cheat code input sequence (null-terminated) |
0x283d |
Cheat sequence match position counter |
0x7ded |
Map X coordinate array (2 bytes per entry) |
0x7df1 |
Map Y coordinate array (2 bytes per entry) |
0x7df5 |
Map Z array (1 byte per entry) |
0x7df9 |
Entity state array (2 bytes per slot) |
0x7e46 |
Player state block far pointer |
0x7e1e |
Entity type table (stride 0x79) |
Entity Vtable Index (NE Segment 1)
| Address | Entity Class |
|---|---|
0x28b5 |
Dialog/menu object vtable |
0x287b |
Cheat-spawned entity (cheat ON) vtable |
0x2892 |
Cheat-spawned entity (cheat OFF) vtable |
0x2969 |
Entity registry vtable (stored at 0x39ca+slot*4, not entity's own vtable) |
0x297e |
Shot/projectile entity vtable |
0x29aa |
Generic/AI entity vtable |
0x2a1a |
Corpse entity vtable (variant) |
0x2a33 |
Actor/corpse entity vtable |
0x2a57 |
Debris fragment entity vtable |
Cheat System Analysis
cheat_code_check internals
- Direct raw-EXE recovery:
cheat_code_checkinCRUSADER-RAW.EXEis0007:0d0a-0007:0e08. - It has exactly one direct caller in this build:
FUN_0007_04dcat0007:0511, which prepares a small local event record and then callscheat_code_checkbefore continuing normal input dispatch. FUN_0007_04dcitself is not only a low keyboard-queue consumer:drawlist_initat0007:f654calls it directly during higher-level setup, so the cheat-dispatch body can be reached from at least one non-ISR path in this build.
Variable and constant roles from the recovered body:
0x2833is not a clean dedicated data object in this raw EXE — raw bytes and surrounding disassembly show that it lands in the middle ofentity_animation_frame_update(0007:26e2-0007:2867). Starting onPUSH AX; CMP byte ptr [0x27fd],0; ...the first five bytes are50 80 3e fd 27followed by a0x00.0x283dis the current match index into that byte table. On a successful byte match it increments; on mismatch it resets to zero and immediately retries the current input byte against the first table byte (overlapping prefixes still work).input_event_record[+1]is the compared input/event token.0x844is the main cheat-enable flag. The success path toggles it by converting the current byte to a boolean and negating it (0 -> 1, non-zero ->0).0x6045is written with the same post-toggle value as0x844— a mirrored cheat-state latch.- Constant
0x103is pushed into the shared helper at000a:5276immediately after the toggle (emit the cheat-toggle side effect). 0x8c52is forced to1on success before the side-effect path continues.
After a full table match, the code resets 0x283d to zero, sets 0x8c52 = 1, toggles 0x844 and 0x6045, and calls the shared 0x103 helper. It then branches on the new cheat state: cheats-on uses DS:0x287b; cheats-off uses DS:0x2892.
Cheat-enable sources
Two independent cheat-enable sources verified:
- The hidden input matcher in
cheat_code_checktoggles0x844and0x6045after matching the five-byte event-code table at0x2833. - The command-line parser at
0004:635c-0004:63b8recognizes the literal switch-laurieand directly sets0x844 = 1. This path does not write0x6045.
Current model for the two cheat bytes:
0x844= master cheat-permitted / cheat-framework-enabled flag.0x6045= live cheat-active mirror latch used by low-level keyboard handling.- The hidden five-byte matcher enables both at once, but
-laurieonly enables the master flag. - The separate event-
0x7epath at000c:942drequires0x844 != 0, flips0x6045, and displays one of two local notification messages (0x6087vs0x6091).
jassica16 status:
- No literal
jassicastring is present in the normal runtime string table or the verified command-line parser, while-laurieis present as plain text. - The live NE export/naming trail does preserve older user-defined symbol names for the matcher cells (
g_jassica16Scansat1020:2833andg_jassica16Offsetat1020:283d), which fits the long-standing community name even though the compiled matcher source bytes are not a plain ASCII string in this build. - The matcher bytes themselves are now rechecked directly in the live NE image:
24 1e 1f 1f 17 2e 1e 02 07 00, which is the scan-code sequence forj a s s i c a 1 6. - That matters for runtime testing: the trailing
1and6are the top-row digit scan codes (0x02,0x07), not numpad digits. If those keys are entered through a different scan-code-producing path, the matcher will not complete. - The ordinary keyboard ISR producer still does not support the old byte-for-character model cleanly: it feeds normalized scan-code-style values into record byte
+1, while the matcher source at0x2833is a live code-byte sequence with two values (0x80,0xfd) that do not fit that ISR path. - The direct call
drawlist_init -> FUN_0007_04dcis the first concrete static evidence for a higher-level path in this build. - Observed runtime behavior now fits the toggle model cleanly: if
-lauriehas already forced0x844 = 1, triggering the hidden matcher again will toggle both0x844and0x6045back to0, which explains the user-observed "jassica16 disables cheats when laurie is active" behavior. - Live data-use recovery tightens the latch story further:
0x6045is written only byKey_CheckCheatToggle(1130:2b72) and the separate event-0x7eruntime toggle (13e8:203d). So ifjassica16really completes and no later0x7etoggle fires, the low-level cheat latch should stay on.
F10 key behavior (verified in raw build):
seg001_input_keyboard_handlerat0006:ec29handles input byte0x44and immediately returns unless cheats are enabled through0x6045.- "Plain F10 when cheats are enabled" is verified, and the immortality toggle is a separate Ctrl-gated F10 sub-branch.
- Live NE cross-check:
Key_HandleOptionKeysat1130:0896/0007:0a36confirms the same gating and sharpens the keyboard result further. Once the low-level cheat latch0x6045is on, plain F10 runs the large restore/refill/loadout branch, while the modifier-gated F10 sub-branch directly toggles the current controlled NPC's immortal flag and posts the local"Immortality enabled."/"Immortality disabled."messages. - The helper identity is now closed from the code too, not only from runtime behavior.
KeyboardGetExtendedShiftStates(11d0:39e6) uses BIOSINT 16h, AH=12h, whose returned AH bits are0=left Ctrl,1=left Alt,2=right Ctrl,3=right Alt. The helper at11c8:01a8tests0x0100 | 0x0400, so it is reallyKeyEvent_IsCtrlDown; the helper at11c8:018atests0x0200 | 0x0800, so it is reallyKeyEvent_IsAltDown. The older Alt/Ctrl labels were reversed. - The remaining timing question is now closed by the upstream keyboard path too. The held-key repeat builder at
11b8:0129..022bsamples the current BIOS extended-shift state through11d0:39e6, stores that snapshot from31a4into each synthesizedKeyEventat11b8:01d5, and queues the 12-byte event through11d0:3533.Keyboard_GetLastKeyEvent(11b8:0457) later returns that exact snapshot, andController_HandleKeyEvent(1130:2211) copies it before callingKey_HandleOptionKeys. - That engine-side repeat synthesis explains both observed quirks cleanly. Holding
F10first causes the game to keep generating repeated F10 keydown-style events; once physicalCtrlis pressed, later repeated F10 events carry the refreshed modifier snapshot and can satisfy the immortality branch. The same lack of one-shot suppression is why holding the keys too long can flip immortality on and off repeatedly and spawn multiple enable/disable modals. - The live NE string addresses for the F10 immortality messages are
1478:2850="Immortality disabled."and1478:2866="Immortality enabled."The earlier1478:6450/6466note was incorrect. - One more runtime gate is now explicit too: the F10 path first checks
DAT_1478_085fat1130:0a29, then0x6045at1130:0a36. - The
0x85fstate now has a tighter live model. It is set duringGame_Start(1020:0127), cleared at the end ofComputerGump_CreateGump(1398:01f5) just before returning the modal gump, and restored inComputerGump_CloseAndResumeGameplay(1398:0233) during the computer-gump cleanup path before falling into the base-gump teardown. Across the wider codebase it gates controller/joystick/camera and option-key handling, so the current safest read is gameplay input / option-key active state, not a cheat byte. - That makes the paired
1398helpers easier to read too:ComputerGump_CreateGumpsuspends normal gameplay input by clearing0x85f, andComputerGump_CloseAndResumeGameplayappears to be the computer-gump close/teardown override that restores gameplay input, releases any pending text buffer at+0x34/+0x36, refreshes the UI/controller state, and then falls through the generic gump cleanup/free path. - The hottest
0x85freaders are clearer in the live NE database now.Controller_HandleKeyEvent(1130:2334) first checks the separate controller-enable latch at1478:27cb, then forwards intoKey_HandleOptionKeys, and only after that rechecks0x85fbefore allowing ordinary gameplay key processing. The paired13e8wrappers around that flow are now renamedGame_DisableGameplayInputAndRefreshCamera(13e8:0e7d) andGame_RestoreGameplayInputAndClearModalState(13e8:0ef9): together they clear/restore1478:27cb, flip the overlay-suppression byte1478:2c64, toggle transient state1478:8c53, and preserve1478:8c54as a saved copy of1478:2d24while the modal camera/input transition is active. - The Laurie side is narrower too.
Game_ShowLaurieHintComputerGump(13e8:0e31) is the hidden computer-gump hint path that reaches theFART ...TRY... -laurie (Have fun, Jely)string, andGame_ShowLaurieHintIfGameplayInputActive(13e8:0f4a) is only a tiny0x85f-gated wrapper around it. So the Laurie hint path and the F10 immortality path are both suppressed by the same broader gameplay-input gate, even though they are otherwise separate features. - The main camera pass in that same lane is now named
Camera_RedrawViewportAndGameplayOverlays(1180:19c1). It brackets the viewport blit with two newly named overlay helpers,GameplayOverlayWindows_DrawTracked(1188:010f) andGameplayOverlayWindows_ClearDirtyRects(1188:0394), and it also uses0x85fto choose between the avatar-centered redraw rectangle and the wider modal/non-gameplay redraw path. - The
0x85fcallers insideWorld_HandleKeyboardInput_13e8_14b4are now concrete enough to explain user-visible failures better. The same modal disable/restore pair wraps the exit-to-DOS confirmation lane (0x22d), quick save (0x13f), quick load (0x13e), restart/main-menu handling (Game_RestartMaybe), and the load/menu gump lanes around0x142/0x143/0x13c. If play is currently inside one of those modal transitions, the F10 immortality path is suppressed before the cheat latch is even consulted. - The runtime cheat-latch override is also firmer now. Event
0x7einsideWorld_HandleKeyboardInput_13e8_14b4is the only other recovered writer of0x6045besidesKey_CheckCheatToggle, and it independently flips the keyboard cheat latch while only requiring the broader0x844permission gate. So a successfuljassica16match can still be undone later by that separate0x7epath. Key_CheckCheatToggleitself is now comment-backed as an exact scan-code matcher: only keydown events participate, and the final1/6bytes ing_jassica16Scansare still top-row scan codes0x02/0x07. That keeps keypad1/6or any non-scan-code-equivalent input path as a live explanation for failed attempts.
No Regret cross-check
- The currently opened
REGRET.EXEuses the same overall F10 structure but not the same cheat-sequence semantics. ItsKey_HandleOptionKeyslives at1148:0a9a, and the F10 branch at1148:0d0efirst checks1480:0adc, then1480:009b, and then calls11e0:01a8before toggling the controlled NPC's immortal flag and displaying1480:3052 = "Immortality disabled."/1480:3068 = "Immortality enabled.". - Live runtime testing tightened the practical input story further. In both games, the user was only able to trigger immortality by pressing
F10first and then pressingCtrlwhile continuing to holdF10;F10+Altdid nothing. The No Remorse helper swap is now code-proven from the BIOS bit layout.REGRET.EXEalmost certainly follows the same convention in its parallel helper pair, but that target should be reopened and fixed directly before promoting the same renames there. - The hidden key-sequence function is also different enough to matter.
1148:34d2is now renamedKey_CheckSecretCodeSequences. Its first scan-code table at1480:2ff0is stilljassica16(24 1e 1f 1f 17 2e 1e 02 07 00), but in No Regret that sequence triggers the"Of course we changed the cheats..."lane instead of the main cheat latch. - The actual No Regret cheat-toggle sequence is the second table at
1480:2ffc, which decodes asloosecannonplus top-row1/6tail scan codes (26 18 18 1f 12 2e 1e 31 31 18 31 02 07 00). Completing that sequence toggles1480:0ac0and mirrors the result into the low-level F10 latch1480:009b, then posts"Cheats are now active."at1480:30beor"Cheats are now inactive."at1480:30d5. - The repeated modal behavior also now makes sense from the compiled path. The No Regret F10 branch has no one-shot debounce; it only requires a keydown-style event and the modifier helper to pass. So if
F10is held long enough for key repeat, the branch can run again and again, toggling immortality on and off and spawning multiple enable/disable modals in succession. - The strongest No Regret contrast is therefore no longer
AltversusCtrl; it is that the latch-enabling secret code changed fromjassica16toloosecannon, while the physical modifier gesture for the F10 immortality toggle behaves asF10-then-Ctrlin live play. - The immortality sub-branch is also only reached for a live current NPC: after the
0x6045gate, the code callsNPC_IsDeadat10e8:1fed; the modifier-gated F10 path begins only on the zero/not-dead result. - When a current
0x7e22entity exists, the branch resolves the current selection and refreshes per-entity bookkeeping. - In the
local_4 == 1case the branch becomes a large restore/reset routine that tears down and rebuilds multiple linked objects around0x7e22, retries dispatch up to0x14times per stage, and fires the event batch0x33d,0x33f,0x340,0x341,0x33ebefore re-enabling channels4,1, and0.
What -laurie appears to enable by itself:
- It sets the broad cheat-permission flag
0x844and shows the startup-side "Cheats are now active." notification, but it does not set the low-level keyboard cheat latch0x6045. - That means
-laurieis enough for the compiled event handlers gated only by0x844to operate, including the debug-overlay family (0x441,0x241,0x141) and the CD transfer display toggle handler (0x410). - It is also enough to permit the separate event-
0x7eruntime toggle path, whose entire job is to flip0x6045later and post the0x6087/0x6091notifications. - It is not enough for low-level keyboard-only cheat branches that check
0x6045directly. That is why the user can see the0x844-gated debug-box behavior while plain F10 still behaves as if full keyboard cheats are off.
Cheat-related string table (seg014 / 000e:xxxx)
| Address | String | Notes |
|---|---|---|
000e:9c5e |
"FART ...TRY... -laurie (Have fun, Jely)" |
Dev Easter-egg comment; no static code xref |
000e:9c87 |
"CHEATS ON" |
Cheat-on status string |
000e:9c91 |
"CHEATS OFF" |
Cheat-off status string |
000e:9c9c |
"TARGETING RETICLE ACTIVE." |
Correlates to event 0x441 / byte 0xee0 toggle |
000e:9cb6 |
"TARGETING RETICLE INACTIVE." |
Paired off-state |
000e:9cd2 |
"CD TRANSFER DISPLAY ACTIVE." |
Directly matches live event 0x410 / DS:0x604f toggle |
000e:9cee |
"CD TRANSFER DISPLAY INACTIVE." |
Paired off-state |
000e:9dff |
"HACK MOVER ON" |
No static code xref; USECODE/scripting layer |
000e:9e0d |
"HACK MOVER OFF" |
No static code xref; USECODE/scripting layer |
1478:2850 |
"Immortality disabled." |
Used by the modifier-gated F10 immortality branch in Key_HandleOptionKeys |
1478:2866 |
"Immortality enabled." |
Used by the modifier-gated F10 immortality branch in Key_HandleOptionKeys |
000e:647b |
"Cheats are now active." |
Shown in -laurie startup path |
000e:6492 |
"Cheats are now inactive." |
Paired off-state |
Cheat event dispatch summary (000c segment)
All cheat-related event case-handlers reside as shared-frame case bodies within a large event dispatch function in segment 000c. Each body inherits BP from the enclosing prologue and exits via POP DI; POP SI; LEAVE; RETF.
| Address | Symbol | Event | Action |
|---|---|---|---|
000c:8e16 |
event_0x441_cheat_debug_overlay_toggle |
0x441 |
Toggles DS:0xee0 (boolean-NOT); calls [0x2bd8] vtable +0x2c; gate = DS:0x844 |
000c:8e46 |
event_0x241_cheat_debug_overlay_toggle |
0x241 |
Toggles DS:0x2bc9 (1-current); same vtable dispatch; gate = DS:0x844 |
000c:8e72 |
event_0x141_cheat_debug_overlay_toggle |
0x141 |
Toggles DS:0x2bca (1-current); same vtable dispatch; gate = DS:0x844 |
000c:942d |
event_0x7e_cheat_latch_runtime_toggle |
0x7e |
Requires 0x844 != 0; flips live latch DS:0x6045; notification at DS:0x6087 (on) or DS:0x6091 (off) |
000c:9154 |
event_0x142_cheat_fullscreen_mode1_refresh |
0x142 |
Gate = DS:0x604b; palette-black, seg126 shell, mode-1 000c:3c0e, tail 0004:70f1 |
000c:92cd |
event_0x143_cheat_fullscreen_mode0_refresh |
0x143 |
Same as 0x142 but mode-0 000c:3c0e, tail 0004:6f15 |
000c:9703 |
event_0x410_cd_transfer_display_toggle |
0x410 |
Toggles DS:0x604f / g_cdTransferDisplayActive; notification at DS:0x60d2 (active) or DS:0x60ee (inactive); gate = DS:0x844 |
Cheat-dispatch keyboard functions (seg007)
| Address | Name | Description |
|---|---|---|
0007:04dc |
keyboard_input_cheat_dispatch |
Processes one keyboard event: calls cheat_code_check, then dispatches on raw scan-code [record+1]. Tab/J (0x0f/0x24) → context-sensitive entity action via FUN_0005_e119/252; KP* (0x37) → cheat_entity_slot_cycle_and_update_sprite; Space (0x39) → movement/entity_command_dispatch; KP- (0x4a) → cheat_anim_type_cycle_and_refresh; KP+/KP0/KPDel (0x4e/0x52/0x53) → selected object vtable +0x18(0xb,...) dispatch. ASCII H (0x48) absent; HACK MOVER comes from a higher scripting layer. |
0007:0d0a |
cheat_code_check |
Five-byte stateful matcher at DS:0x2833; toggles 0x844+0x6045; cheats-on notification via display_null_check_dispatch(..., 0x287b); cheats-off via display_null_check_dispatch(..., 0x2892). |
Cheat-dispatch helpers (000c:81xx)
| Address | Name | Description |
|---|---|---|
000c:8072 |
cheat_entity_slot_cycle_and_update_sprite |
Cycles slot 1..5 for 0x7e22 entity; picks sprite ID by class flags; calls entity_table_set_sprite. |
000c:81c0 |
cheat_anim_type_cycle_and_refresh |
Cycles animation-type 0x0b..0x19 for 0x7e22; writes per-entity +0x19; calls 0008:4bba(0x20). |
000c:8221 |
cheat_flag_6050_clear |
Clears DS:0x6050 to 0. |
000c:8227 |
cheat_flag_6050_read |
Returns DS:0x6050 in AL. |
000c:822b |
cheat_flag_6050_set |
Sets DS:0x6050 to 1. |
Additional cheat-dispatch hotkeys in keyboard_input_cheat_dispatch
Verified byte tests in the caller-side dispatch:
0x37calls000c:8072(cheat_entity_slot_cycle_and_update_sprite)0x4acalls000c:81c0(cheat_anim_type_cycle_and_refresh)0x0fand0x24share a context-sensitive branch viaFUN_0005_e252with event IDs0x3a,0x38, or0x0b0x39and0x52share a branch computing a queued delta viaentity_command_dispatch0x4eand0x53are separate guarded selected-object lanes dispatching through the selected object's method table
F10 immortality vs Ctrl+Q CD transfer display
The live NE decompile now separates these behaviors cleanly.
- Direct keyboard immortality lane: inside
Key_HandleOptionKeys(1130:0896/ live F10 branch at1130:0a36), once full keyboard cheats are active through0x6045, the modifier-gated F10 sub-branch toggles the current controlled NPC's immortal flag directly viaNPC_GetIsImmortal/NPC_SetImmortal/NPC_ClearImmortal. Live runtime testing now says the practical gesture is holdF10first and then press physicalCtrl. - Cheat-only CD transfer display lane: event
0x410reaches the compiled handler at000c:9703(13e8:2303live), which togglesDS:0x604f/g_cdTransferDisplayActiveunder the broader0x844gate and posts the strings at1478:60d2/1478:60ee:"CD TRANSFER DISPLAY ACTIVE.""CD TRANSFER DISPLAY INACTIVE."
That means the older disasm scratch note in crusader-disasm/misc_crusader_notes.txt (CTRL-Q = 0x410) now lines up well with the user's runtime observation: Ctrl+Q is the historical control-key note for the CD transfer display toggle, not for immortality.
The immortality status strings are separate. The live NE decompile plus disassembly of Key_HandleOptionKeys directly shows the modifier-gated F10 immortality branch using:
"Immortality enabled."at1478:2866"Immortality disabled."at1478:2850
The same compiled proof also sharpens the modifier claim beyond the earlier folklore-level read:
1130:0afdcallsKeyEvent_IsAltDowninside the F10 cheat branch.- The F10 branch then chooses between the
1478:2850and1478:2866strings. - There is no
KeyEvent_IsCtrlDowntest anywhere in that F10 branch. - The
KeyEvent_IsCtrlDowncall at1130:0cadbelongs to a later, unrelated branch inKey_HandleOptionKeys, not to F10.
Current strongest read:
- The recovered compiled path still reaches a single modifier helper from the F10 immortality branch rather than testing both modifier families in that branch.
- Live runtime behavior now says the practical gesture is
F10-then-Ctrlonce the0x6045cheat latch is active. Ctrl+Q/ event0x410toggles the CD transfer display state instead.- If
jassica16has been entered and the F10 immortality gesture still appears dead in live play, the most likely compiled-code explanations are: the scan-code matcher never actually completed, the cheat latch was toggled back off, the broader gameplay-input state at0x85fis down because the game is in a modal/non-gameplay state, or the key never reaches the game at all. - The most practical runtime confirmation point is the cheat notification: a successful matcher pass goes through
Key_CheckCheatToggleand pushesDS:0x287b(Cheats are now active.) orDS:0x2892(Cheats are now inactive.). If that notification does not appear, the latch almost certainly never changed.
Secondary handler (000b:b62c):
cheat_event_listener_handle_event (000b:b62c) receives event 0x410 through the registration installed by cheat_event_listener_create at 000b:b3b1. When event 0x410 arrives, it writes state code 0xe (decimal 14) into the event object's field +0x6 and passes it to 000b:b7f3 for processing. This is a parallel state-machine path that runs alongside the 000c toggle; likely it drives an associated USECODE process or animation object into state 14.
| Address | Symbol | Role |
|---|---|---|
000b:b3b1 |
cheat_event_listener_create |
Allocates one seg109 listener object and subscribes it to the shared cheat/control event bundle that includes 0x410. |
000b:b62c |
cheat_event_listener_handle_event |
Subscriber-side event mapper: rewrites incoming 0x410 to local state 0x0e before entering the shared listener state machine. |
DS:0x604f |
g_cdTransferDisplayActive |
Set/cleared by event 0x410. |
DS:0x60d2 |
CD-transfer-on notification ptr | Near pointer in DS; resolves to far ptr → "CD TRANSFER DISPLAY ACTIVE." |
DS:0x60ee |
CD-transfer-off notification ptr | Near pointer in DS; resolves to far ptr → "CD TRANSFER DISPLAY INACTIVE." |
000a:b988 |
video_bios_state_snapshot |
Called after notification display in the 0x410 toggle to refresh screen state. |
Hidden cheat menu investigation (seg109 UI lane)
New compiled-side evidence shows that the old "hidden cheat menu" label is misleading. The seg109 UI path is much closer to a hidden usecode debugger / unit inspector than to a retail cheat list:
| Address | Symbol | Role |
|---|---|---|
000b:9a86 |
usecode_debugger_open_for_current_unit |
Builds the seg109 debugger gump in current-unit mode, derives a usecode path from the live runtime context (0x659c/0x659e), loads that file, centers the current line, and then runs a modal UI loop. |
000b:9c0d |
usecode_debugger_open_modal |
Smaller generic modal wrapper that opens the same debugger UI without first preloading a current unit/file. |
000b:b3b1 |
usecode_debugger_gump_create |
Constructor for the debugger object. Builds the panes/menu bar, initializes watch state, resolves the base usecode path, and registers the shared control-event bundle including 0x23f, 0x410, 0x411, and 0x441. |
000b:b62c |
usecode_debugger_handle_event |
Debugger event mapper; recovered cases are debugger-style commands (open unit/file, go to line, watch, inspect, clear watches, change global, find/search again, break to debugger). Event 0x23f is reused as a local debugger-state command; event 0x410 remaps to local state 0x0e before entering the shared tail. |
000b:2882 |
usecode_debugger_build_menubar |
Builds the top-level debugger menus: File, Run, Breakpoints, Search, and Data. Recovered entries include Open Unit, View File, Run to cursor, Trace into, Step over, Run until return, Toggle F2, Break to TDP, Find, Search again, Go to line, Watch, Inspect, Change Global, and Quit. |
This better explains the long-running negative result about the "infamous scrollable cheat menu": the hidden UI we can actually recover is not a plain scrollable cheat list at all. It is a modal debugger/unit-inspector front-end that expects valid usecode file context and developer-style command routing.
Reachability status in retail binary
- Static constructor callsites for
usecode_debugger_gump_createare exactly two locations:000b:9a9band000b:9c56. - Static inbound xrefs to the wrapper entries
000b:9a86and000b:9c0dare currently empty in the recovered code graph. - The cheat-code matcher
cheat_code_check(0007:0d0a) toggles0x844/0x6045and emits event0x103; it does not call these menu wrappers directly. - The 000c handler for
0x103(000c:99dd) executes a status/refresh lane and notification path; no direct call tousecode_debugger_gump_createappears there.
Current best read: this debugger path is compiled and functional at object level, but likely orphaned/hidden in final gameplay flow (possibly dev-only entry removed, or only reachable through non-recovered data-driven callback wiring). That orphaned status is a better fit for the missing retail cheat menu than assuming a still-live player-facing scrollable cheat list.
Breakpoint callback lane (new strongest orphan candidate)
The next live NE pass shifts the strongest likely entry point away from the cheat-toggle helper and toward a surviving usecode breakpoint callback lane in seg1408/seg1418.
New live renames in this lane:
| Live NE | Name | Role |
|---|---|---|
1408:0000 |
usecode_debugger_break_state_create |
Allocates and initializes the seg1408 debugger-state object: breakpoint table, current-entry stack, and run-mode flags. |
1408:0053 |
usecode_debugger_maybe_break_on_current_line |
Runtime breakpoint gate. Stores the current line into the debugger state, resolves the current unit/file name through 1408:0444, checks the breakpoint table through 1408:029e, and callbacks through the object's vtable when a break condition is met. |
1408:00dd |
usecode_debugger_breakpoint_insert_sorted |
Inserts (file,line) breakpoint entries into the seg1408 table in sorted order. |
1408:029e |
usecode_debugger_has_breakpoint |
Exact (file,line) membership test over the seg1408 breakpoint table. |
1408:03b0 |
usecode_debugger_callstack_push_entry |
Pushes one current-unit/current-line debugger entry into the seg1408 stack. |
1408:03f7 |
usecode_debugger_callstack_pop_entry |
Pops one debugger callstack/current-entry record. |
1408:0419 |
usecode_debugger_enable_single_step |
Arms the step/run-state flags that make the next interpreter lane callback eligible. |
1408:0432 |
usecode_debugger_clear_step_state |
Clears the step/run flags. |
1408:0444 |
usecode_debugger_current_entry_get_unit_name |
Returns the active unit/file name pointer from the current debugger entry stack. |
This matters because 1418:04aa..04b5 is now comment-backed as a concrete interpreter-side handoff into usecode_debugger_maybe_break_on_current_line:
- it first checks whether
0x659c/0x659eis non-null, - then pushes the current interpreted line,
- then calls
1408:0053.
That means the live binary still contains a generic "break here if debugger state exists" lane in the usecode interpreter.
Current best orphan model:
0x659c/0x659eis not just passive current-unit metadata; it behaves like the far pointer to the seg1408 debugger-state object.- The seg109 UI wrappers consume that same object shape naturally.
usecode_debugger_open_for_current_unit(13a0:0086) is especially consistent with this model because it expects a live current-unit state, resolves the active unit filename, loads the corresponding usecode file, centers on the current line, and then enters the modal UI. - Direct static inbound xrefs to
13a0:0086/13a0:020dcan therefore stay empty even if the original debugger entry was real, because the missing handoff could have been callback/vtable based rather than a directCALLFto the wrapper. - The current negative result on
usecode_debugger_break_state_createis important too: neither the live instruction search nor the repo relocation corpus currently shows a surviving retail constructor call for1408:0000. If that object is never instantiated and stored into0x659c/0x659e, the interpreter breakpoint hook stays compiled but dormant, which is exactly the orphan pattern now seen in the retail binary.
This moves the strongest likely original entry point from "cheat code success calls the menu directly" to "a debugger-state object at 0x659c/0x659e used the seg1408 breakpoint callback path to reach the seg109 current-unit debugger UI, but the retail build no longer instantiates or wires that object".
Practical force-enable paths now split more cleanly:
- Closest to the apparent original design: instantiate
usecode_debugger_break_state_create, store the far pointer into0x659c/0x659e, and ensure its callback/vtable target opens13a0:0086or13a0:020d. Then let the existing interpreter callback at1418:04b5trip normally. - Executable patch near the surviving hook: patch the seg1408 callback target or the
1418:04b5breakpoint handoff so a valid debugger-state object enters the seg109 UI when a line/step condition fires. - Blunt modal force-open: keep using the already-documented cheat/event retarget experiments (
1130:2b78,13e8:25e0) when the goal is only to prove UI reachability, not to reconstruct the original control flow.
Usecode-script viability as an alternative entry path
Cross-referencing the live NE work, the crusader-disasm usecode listings, and the ScummVM Crusader intrinsic table now gives a more precise answer to "can usecode do this?": partially, but probably not directly enough to replace an EXE-side debugger-state fix.
What the current evidence says:
- Crusader usecode clearly can open several normal modal UI paths. The ScummVM Remorse intrinsic table exposes
CruStatusGump::I_showStatusGump(Intrinsic05F),KeypadGump::I_showKeypad(Intrinsic0C4),ComputerGump::I_readComputer(Intrinsic0FE), andWeaselGump::I_showWeaselGump(Intrinsic134). So a script hack that opens an ordinary gump is completely plausible. - The same table does not expose anything that reads like "open usecode debugger", "construct debugger state", or "register breakpoint callback". That matters because the strongest compiled-side entry model now depends on the missing seg1408 debugger-state object at
0x659c/0x659e, not just on some generic modal UI primitive. - The compiled-side breakpoint lane still expects a live debugger object.
1418:04aa..04b5only reachesusecode_debugger_maybe_break_on_current_linewhen0x659c/0x659eis already non-null, and the retail binary still has no recovered constructor path forusecode_debugger_break_state_create. Nothing in the current usecode evidence shows a script-visible way to instantiate that object or store it into the required global far pointer. - The script/event frontier remains data-driven rather than explicit. Extracted usecode still points to
EVENT,_BOOT, and especiallyNPCTRIGas the strongest active-event families, whileSURCAMNS/SURCAMEWremain callback-styleeventTriggerholders rather than proven active-event cores. The direct body scan also still finds no inline0x0410/0x00000410literal inEVENT,NPCTRIG,SPECIAL, orTRIGPAD, so the existingCtrl+Q/0x410lane does not currently look like a plain script literal we can just drop into a chest body.
Accessible object candidates do still matter, but they split into better and worse testbeds:
MONITNS/ computer-adjacent objects are the best script-side probe.crusader-disasmplaces the first monitor at item9254, shape258, coordinates(60798,59518,24), and itsMONITNS::use()body is live. That family is already adjacent to normal computer/camera UI behavior, which makes it a better fit for testing whether usecode can be coerced into a hidden developer-facing UI transition.SURCAMNS/SURCAMEWare also stronger than a chest for experimentation. Their usecode bodies already move the camera and spawn follow-up ordinals, and descriptor-side work shows expliciteventTriggerfields plus repeated callback-oriented bodies. Even so, the current extractor evidence still treats them as callback holders, not as proven direct emitters of the seg109 debugger/control bundle.NPCTRIGremains the strongest compact event-bearing family if the real route is "usecode event machinery eventually reaches a hidden control path." Its slot0x0Abody is the best surviving active-event frontier, but current binary work still bottoms out at "decoded VM workspace / caller stream" rather than a direct, script-readableopen debuggeroperation.CHEST_EWis a weak candidate if the goal is specifically to reach the hidden debugger. The first chest is easy to reach, but itsCHEST_EW::use()body mostly does chest animation, audio, waits, and aFREE::ordinal2Dobject-creation path. That makes it fine as a general proof-of-hack host, but not an evidence-backed match for the debugger/control lane.
Current best practical answer:
- Viable for experimentation: yes. A usecode mod can almost certainly be attached to an accessible early object, and a monitor/computer-style object is a better host than a chest.
- Viable as a clean direct debugger-launch substitute for EXE patching: not yet. The strongest known hidden-debugger entry still depends on missing compiled-side debugger state, and current usecode research has not surfaced a script-visible primitive that recreates that state or calls the seg109 debugger wrappers directly.
- Most defensible usecode-first experiment: hijack an early monitor / computer-adjacent use handler (
MONITNSfirst,SURCAM*second) and try to route it into an already-existing modal/UI-bearing engine path, while treatingNPCTRIG/EVENTas the deeper data-driven frontier if the real control path turns out to be event-mediated.
Retail patch-targeting trail
The practical patch work ended up being mostly about finding a call site whose runtime context matches the hidden menu wrappers, not just finding any place that reaches 000a:5276.
Verified retail anchor points:
| File off | Ghidra | Meaning | Notes |
|---|---|---|---|
0x70d75 |
0007:0d75 |
cheat matcher emits event 0x103 |
retail bytes = 68 03 01 9A FF FF 00 00 83 C4 02; NE fixup source = 0007:0d79 -> seg092:0476 |
0x71d68 |
fixup entry for 0007:0d79 |
seg039 relocation record | exact retail entry: addr_type 0x03, rel_type 0x00, chain_off 0x2b79, target seg092:0476 |
0xc99dd |
000c:99dd |
later controller-side handler that also executes push 0x103 / call 000a:5276 |
retail fixup source = 000c:99e0 -> seg092:0476; this is the first materially safer deferred hook candidate after the direct matcher path failed |
0xb9a8d |
000b:9a8d |
arg setup inside cheat_menu_open_from_current_slot |
original wrapper uses caller stack words [BP+8] and [BP+6] plus local armed flag 1 |
0xb9c48 |
000b:9c48 |
modal wrapper prologue; the inherited caller-word patch subsite is 000b:9c4e / live 13a0:024a |
original wrapper still feeds caller stack words [BP+8] and [BP+6] into cheat_event_listener_create, but starts with local defaults -1, -1, 0 |
Live NE CRUSADER.EXE mapping in Ghidra
The older file offsets and raw-style segment addresses remain useful provenance, but the patch should now be planned against the live NE program that is open in Ghidra.
The following locations are confirmed directly in the live CRUSADER.EXE listing:
| Live NE Ghidra | Raw/reference anchor | Meaning |
|---|---|---|
1130:2b75 |
0007:0d75 |
cheat_code_check success lane: toggles 0x844/0x6045, then emits event 0x103 via existing CALLF 12d8:0476 at 1130:2b78 |
13a0:0086 |
000b:9a86 |
usecode_debugger_open_for_current_unit; larger hidden debugger wrapper and current best direct retarget target |
13a0:008d |
000b:9a8d |
current-slot constructor arg site: PUSH 1, PUSH [BP+8], PUSH [BP+6], PUSH 0, PUSH 0, CALL 13a0:19b1 |
13a0:020d |
000b:9c0d |
usecode_debugger_open_modal; smaller modal wrapper |
13a0:0244 |
000b:9c48 |
modal wrapper prologue; inherited caller-word patch subsite is 13a0:024a |
13a0:19b1 |
000b:b3b1 |
usecode_debugger_gump_create; registers the shared debugger/control event bundle including 0x410 |
13a0:1df3 |
000b:b62c |
usecode_debugger_handle_event; debugger-side dispatcher that remaps incoming 0x410 to local state 0x0e |
13e8:2303 |
000c:9703 |
compiled CD transfer display toggle handler for event 0x410; boolean-toggles DS:0x604f / g_cdTransferDisplayActive and posts the active/inactive notifications |
13e8:25dd |
000c:99dd |
deferred controller-side 0x103 lane; the live call opcode begins at 13e8:25e0 and prior 0x42f retarget tests hit the retail FLEX.C line 83 failure |
Provenance split:
crusader-disasmand the older retail-offset patch notes were used only to recover candidate lanes and preserve file-format history.- The target selection above is confirmed from the live NE
CRUSADER.EXEdisassembly and comments now stored in Ghidra itself.
Live cheat-data anchors now comment-backed in Ghidra:
| Live NE data | Meaning |
|---|---|
1020:2833 |
5-byte cheat matcher table consumed by cheat_code_check |
1020:283d |
cheat matcher index/state byte advanced during sequence validation |
1020:0844 |
cheats_enabled gate byte checked before event 0x410 can toggle the CD transfer display |
1020:6045 |
status/mirror byte updated alongside 1020:0844 when the cheat matcher succeeds |
1020:604f |
g_cdTransferDisplayActive; toggled by the compiled 0x410 handler |
1020:6050 |
secondary cheat-related state from the older activation lane; distinct from the 0x410 CD transfer display toggle |
One remaining function-hygiene caveat:
- The live
0x410handler body at13e8:2303is comment-backed and behaviorally clear, but it still sits inside the oversizedWorld_HandleKeyboardInput_13e8_14b4function object in the current NE database. That is why this batch documents the handler in place instead of forcing a boundary repair just to land a new function name.
What failed and why:
- Direct retarget of
0007:0d79to000b:9a86crashed at startup when the NE relocation table was patched incorrectly as a raw far pointer. That was a file-format problem, not a semantic proof. - After the patcher was made NE-fixup-aware, direct retarget to
000b:9a86no longer broke startup, but the game hung when the cheat actually fired. Disassembly shows why:cheat_menu_open_from_current_slotconsumes caller-supplied words at[BP+8]and[BP+6], so the cheat matcher context is the wrong stack shape. - Retargeting the same early cheat-matcher call to
000b:9c0dgot farther: the mouse pointer appeared, proving the hidden menu/display path was being entered. But it still hung with looping music, which points to timing/context, not a bad target address. The modal path appears unsafe when entered directly from the keyboard matcher even after the constructor args are forced to zero. - The narrower direct current-slot patch was then runtime-tested on
/Writable/CRUSADER-PATCHED.EXEwith bytes verified as1130:2b78 = 9A 86 00 A0 13and13a0:008d = 6A 01 6A 00 6A 00 90 90. User test result: the normal cheat-toggle path still appeared, but no hidden menu appeared. That closes the direct current-slot route as a practical candidate, not just a theoretical one.
Current best patch rationale:
0007:0d75remains the verified cheat-sequence success site, but the direct1130:2b78 -> 13a0:0086retarget is no longer the best live patch because it has now failed both analytically and at runtime.- The first materially safer deferred hook remains the controller-side
000c:99ddlane, where the live call opcode begins at13e8:25e0. That path preserves the real0x103event context instead of substituting0x42f, which is the strongest evidence-backed difference from the rejected deferred experiment. - The chosen writable patch therefore restores
1130:2b78toCALLF 12d8:0476, restores13a0:008dto the original current-slot wrapper bytes, retargets13e8:25e0to13a0:020d(cheat_menu_open_modal), and zeros only the inherited caller-word pushes at13a0:024awhile preserving the modal wrapper's leading local defaults (PUSH -1,PUSH -1,PUSH 0). - The deferred
0x42fbranch remains negative evidence only: it proved the modal wrapper can enter the hidden UI path, but it also proved that substituting the event id or landing in the wrong deferred context trips the retailFLEX.Cfailure.
Rejected follow-up patch design:
- Site 1 tried changing
0007:0d75frompush 0x103topush 0x42f, keeping the original event-dispatch helper call intact. - Site 2 retargeted the
000c:99e1relocation so the0x42fhandler's internalpush 0x103 / call 000a:5276sequence calledcheat_menu_open_modalinstead. - Site 3 patched
000b:9c48from6A 00 FF 76 08 FF 76 06to6A 00 6A 00 6A 00 90 90.
Observed result on retail test build:
- The game no longer failed at startup, and the mouse pointer appeared when the cheat fired, confirming that the hidden modal UI path was being entered.
- But the game then halted with the retail
FILE\FLEX.C, line 83failure and dropped into the quit/teardown path ("No pity. No mercy. No remorse."). - That is strong evidence that event
0x42fis the wrong deferred hook context for this experiment even though the retargeted address itself was valid enough to enter the UI path.
Current Ghidra-side patch plan for a copy:
- Open the writable
/Writable/CRUSADER-PATCHED.EXEprogram in Ghidra or PyGhidra, not the raw full-EXE database. - Restore the disproven direct-hook sites:
1130:2b78back to9A 76 04 D8 12(CALLF 12d8:0476) and13a0:008dback to6A 01 FF 76 08 FF 76 06. - Navigate to the later controller-side
0x103lane at13e8:25e0and retarget thatCALLF 12d8:0476operand to13a0:020d(cheat_menu_open_modal), yielding bytes9A 0D 02 A0 13. - Navigate to
13a0:024ainsidecheat_menu_open_modal. Replace only the inherited caller-frame pushes withPUSH 0/PUSH 0(6A 00 6A 00 90 90) and leave the leadingPUSH -1,PUSH -1,PUSH 0defaults intact. - Do not reintroduce the
0x42fsubstitution or the direct13a0:0086current-slot hook in the same test build. They are now negative evidence, not live candidates.
These edits are now applied and byte-verified on /Writable/CRUSADER-PATCHED.EXE. The live NE CRUSADER.EXE analysis database remains documentation-only for this batch.
Rationale for the revised wrapper patch:
- Earlier direct-hook attempts proved that inheriting the two caller-frame words at
000b:9a8f/9a92is unsafe from the cheat matcher context. - But later decompilation of
cheat_event_listener_createshowed that the leadingpush 0x1at000b:9a8dis a distinct mode byte used by the constructor path, so zeroing all three pushed values was too aggressive. - The current patch therefore preserves the leading
1and only forces the two ambiguous 16-bit parameters to zero.
Risk notes:
- These remain behavioral exploration hacks, not correctness fixes.
- The evidence now strongly suggests the hard part is runtime context and event timing, not discovering the retail file offsets.
- If the revised direct
0007:0d79 -> 000b:9a86path with the narrower000b:9a8dwrapper patch still fails, the next step should be a queue/defer design or a trampoline/cave patch rather than another blind event substitution.
Conservative folklore verification
- "Cheats can be enabled with
-laurie" is directly verified. - "There is a hidden five-byte matcher that toggles cheats" is directly verified.
- "F10 performs a large cheat-only restore/reset action" is directly verified.
- The current live NE decompile alone does not cleanly settle the physical Ctrl-vs-Alt labeling without the runtime correction.
- "The F10 immortality branch directly toggles the current controlled NPC's immortal flag once full keyboard cheats are active" is now directly verified in
Key_HandleOptionKeys. - Live runtime testing now says the practical physical input is
F10-then-Ctrl, so the current helper naming should not be treated as definitive physical-key proof on its own. - "Ctrl+Q shows
CD TRANSFER DISPLAY ACTIVE.when cheats are enabled" now matches the live NE0x410handler and the historicalcrusader-disasmcontrol-key note. - "H enables hack mover" is real at runtime (strings confirmed), but not found in the static low-level byte dispatch; the activation comes from the USECODE scripting layer.
- "Immortality makes the player invincible" is partially verified: damage is divided by 262,144, making HP loss negligible; the hit stagger still plays. There is no bypass of the HP system entirely.
- "Event 0x410 is emitted by a recovered static keyboard path" is still not supported in compiled C code.
- "There is no keyboard immortality combo at all" is now false: the live NE controller option-key handler directly verifies a modifier-gated F10 keyboard immortality toggle once the
0x6045cheat latch is active, and live runtime testing shows the practical gesture isF10-then-Ctrl. TELEPADslot0x20inclass_event_index.tsvis not direct0x410event evidence; its0x00000410value is the extracted class-body offset for that slot.- Among the requested USECODE families,
NPCTRIGis the strongest remaining player-trigger candidate because it is explicitly event-bearing and also has extracted callable bodies, whileTRIGPAD,SPECIAL, andREB_PADcurrently read as neighboring referent/state/controller bodies rather than direct event carriers. - The hidden five-byte matcher compares bytes from live code at
0007:2833, and the ordinary keyboard ISR producer does not naturally emit byte values0x80and0xfdinto record byte+1.