Skip to content

Crash: null getContainedBy() deref in TransportContain::isSpecificRiderFreeToExit (SIGSEGV at 0x158) via AIExitState #246

Description

@mhb8898

Summary

TransportContain::isSpecificRiderFreeToExit() dereferences specificObject->getContainedBy() without a null check, so any call where the rider is not (or no longer) contained crashes the game with SIGSEGV on the main thread.

The null check exists only as a DEBUG_ASSERTCRASH, which compiles out of release builds — the release build then dereferences the same pointer on the very next line.

Hit three times in a row on the macOS arm64 build, in three consecutive multiplayer network games across two maps — every time with an identical stack, identical crashing instruction and identical fault address. This is not a rare race; it is reliably reachable in normal early-game play.

A replay of the third crashing game is available (see below) — it is the auto-saved 00000000.rep, written 1 second before the crash, so it covers the run right up to the faulting frame.

Steps to reproduce

  1. Start a 2-player LAN/network game, China Infantry General vs. USA Superweapon General
  2. Play normally through the opening
  3. Crash occurs in the first ~5 minutes of the match

All three crashes, back to back:

# Incident ID Launched Crashed Time in process
1 2EFFB021-A070-49A3-9DA0-7CC816B82D12 22:08:16 22:15:46 7.5 min
2 6165BDB3-5F40-4564-9FAC-F71CBD213DED 22:18:40 22:24:14 5.5 min
3 6B6925A6-61A2-4B48-B588-21077F298C92 22:24:59 22:28:34 3.5 min

Crash #3 was on the official map Twilight Flame; the earlier ones were on a different map, so this is not map-specific. Early-game China Infantry play means Troop Crawlers are in use, which is the obvious TransportContain candidate on that side.

Note this is a network game: the crash is inside GameLogic::update(), so the affected client is dropped mid-match.

Crash signature

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000158
esr: 0x92000006 (Data Abort) byte read Translation fault

Thread 0 Crashed:: Dispatch queue: com.apple.main-thread
0  TransportContain::isSpecificRiderFreeToExit(Object*) + 56
1  non-virtual thunk to TransportContain::reserveDoorForExit(ThingTemplate const*, Object*) + 24
2  AIExitState::update() + 164
3  StateMachine::updateStateMachine() + 104
4  AIUpdateInterface::update() + 52
5  GameLogic::update() + 796
6  GameEngine::update() + 172
7  GameEngine::execute() + 92
8  SDL3GameEngine::execute() + 56
9  GameMain() + 208
10 main + 1336

All three incidents share the same binary (slice_uuid 5182a493-e4a8-318c-856d-579b258920e1), the same crashing instruction (__TEXT+0x1A0064, i.e. isSpecificRiderFreeToExit + 56), and the same faulting address 0x158.

Register state at the fault is consistent across all three:

Incident x20 (specificObject) x8 (getContainedBy()) far
2EFFB021-… 0x7f4451fa0 0 0x158
6165BDB3-… 0x87f8fd660 0 0x158
6B6925A6-… 0x794042400 0 0x158

specificObject itself is always a valid heap pointer — it is specifically its container back-pointer that is null. So this is a genuinely uncontained rider reaching the check, not a wild/garbage Object*.

Root cause

GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Contain/TransportContain.cpp:582-587
(identical code in Generals/.../TransportContain.cpp:471-476):

#if !RETAIL_COMPATIBLE_CRC
	// TheSuperHackers @bugfix Stubbjax 02/03/2026 If our parent container is held, then we
	// are not free to exit.
	DEBUG_ASSERTCRASH(specificObject->getContainedBy(), ("rider must be contained"));
	if (specificObject->getContainedBy()->isDisabledByType(DISABLED_HELD))
		return FALSE;
#endif

DEBUG_ASSERTCRASH is a no-op in release, so line 585 dereferences a NULL Object*.

Disassembly confirmation

Instruction stream around the fault (from the crash report's instructionByteStream, arm64):

    mov  x20, x1              ; x20 = specificObject
    ldr  x19, [x0, #0x18]     ; x19 = me = getObject()
    ldr  x0,  [x19, #0x1f8]   ; me->getAIUpdateInterface()
    cbz  x0,  skip            ;   <-- null-checked (line 578: `if (ai && ...)`)
    ldr  x8,  [x0]
    ldr  x8,  [x8, #0x390]
    mov  x1,  x20
    blr  x8                   ; ai->getAiFreeToExit(specificObject)
    cbnz w0,  return_false    ; line 578-579
    ldr  x8,  [x20, #0x220]   ; specificObject->getContainedBy()   <-- NOT null-checked
--> ldrb w8,  [x8, #0x158]    ; *** FAULT: x8 == 0, reads 0x0+0x158 ***
    tbnz w8,  #3, return_false; isDisabledByType(DISABLED_HELD)   (DISABLED_HELD == 3)
    mov  x0,  x19
    bl   ...                  ; me->isUsingAirborneLocomotor()      (line 590)
    ...
    ldr  x8,  [x20, #0x1f8]   ; specificObject->getAIUpdateInterface()
    cbz  x8,  return_false    ;   <-- null-checked (line 594)

At the fault: x8 = 0, far = 0x158, and tbnz w8, #3 matches DISABLED_HELD == 3 in Common/DisabledTypes.h. The two neighbouring pointer loads (ai on line 578, getAIUpdateInterface() on line 594) are null-checked; the getContainedBy() load between them is not.

Why the pointer is null

The call arrives from AIExitState::update()ExitInterface::reserveDoorForExit()isSpecificRiderFreeToExit(). AIExitState asks the exit interface whether a unit may leave; nothing on that path guarantees the unit is still inside the container at that moment — e.g. it was already removed/killed/transferred on an earlier frame while the exit state was still pending. The DEBUG_ASSERTCRASH on line 584 shows the author was already aware getContainedBy() can be null here.

Suggested fix

Either null-guard the pointer:

	DEBUG_ASSERTCRASH(specificObject->getContainedBy(), ("rider must be contained"));
	const Object* riderContainer = specificObject->getContainedBy();
	if (riderContainer != nullptr && riderContainer->isDisabledByType(DISABLED_HELD))
		return FALSE;

…or, since a rider inside this transport has getContainedBy() == me, test the container directly and drop the indirection entirely:

	if (me->isDisabledByType(DISABLED_HELD))
		return FALSE;

The first is the minimal, behaviour-preserving change; the second matches the comment's intent ("if our parent container is held") and removes the dependence on the rider's containment state.

Both GeneralsMD and Generals copies need the fix.

Related

Attachments

  • crash-222834.rep — auto-saved replay of crashing game Macos build #3 (Twilight Flame, 2-player LAN), written 1 s before the crash
  • Full .ips crash reports for all three incidents available on request

Environment

  • macOS 26.5.2 (25F84), Apple silicon (Mac16,1, arm64 native, not translated)
  • Renderer: DXVK (libdxvk_d3d9.0.dylib) over MoltenVK / Metal (AGXMetalG16G)
  • Audio: miniaudio; windowing: SDL3
  • Build: GeneralsXZH.app, binary UUID 5182a493-e4a8-318c-856d-579b258920e1 (no version string embedded in the binary)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions