Quick answer: Legacy Objective C games crash through NSException and through signals, and the two need separate handlers. Install NSSetUncaughtExceptionHandler for the Cocoa exception path and sigaction handlers for the POSIX faults, since neither catches the other. Symbolicate with the dSYM archived for that build, even if your toolchain is old. Retrofitting reporting onto an aging codebase is mostly about funneling both failure paths into one report.

Plenty of shipped and still maintained games are Objective C, built years ago and kept alive through ports and updates. Adding crash reporting to a codebase like that is a retrofit job, and Objective C has its own particular failure model. It throws NSException objects, which travel a completely different path from the POSIX signals that memory faults raise, and an older project may sit on a toolchain that predates the conveniences modern Swift code enjoys. This post covers catching the NSException path, catching the signal path, symbolicating with archived dSYMs, and the practicalities of doing all this in a legacy codebase that you cannot freely rewrite.

Two failure paths that do not overlap

Objective C error handling splits cleanly into two worlds. High level failures raise an NSException: an array index out of bounds, an unrecognized selector sent to an object, a key value coding violation. These travel through the Cocoa exception machinery and, if uncaught, terminate the app. Low level faults, a dereferenced freed object under manual retain release, a bad pointer, a stack overflow, never become NSException objects at all; the CPU traps and the OS delivers a signal. A handler for one path is completely blind to the other, which is the single most common gap in legacy crash reporting.

This split is sharpened by Objective C's memory model. Older code using manual retain and release, or even pre ARC patterns, is prone to over release and use after free bugs that surface as EXC_BAD_ACCESS signals rather than tidy exceptions. The objects involved are often already deallocated by the time you crash, so the report has to capture the raw stack and registers, not an object description. Recognizing which path a given crash takes tells you immediately whether you are chasing a logic error in Cocoa code or a memory lifetime bug in the lower layers.

Catching NSException

For the exception path you install a handler with NSSetUncaughtExceptionHandler, a function that receives the NSException for any exception that escapes to the top level. From it you read the exception name, the reason string, the userInfo, and crucially the callStackReturnAddresses and callStackSymbols the exception carries, which give you the stack at the throw site. You format that into a report and write it out. As with all crash handlers, keep the work minimal and avoid anything that might throw again and recurse through your own handler.

A wrinkle specific to this path is that the uncaught exception handler runs after the stack has already unwound in some configurations, so the most reliable stack is the one captured on the exception object itself at throw time rather than the live stack in the handler. There are also longstanding subtleties around exceptions thrown on the main run loop, where Cocoa may catch and continue rather than propagate. Knowing these quirks of an older runtime is part of the job; the report you build should lean on the addresses stored in the exception, not on whatever frames happen to remain when your handler runs.

Catching signals

The signal path is the same as for any native Apple target: sigaction handlers for SIGSEGV, SIGBUS, SIGABRT, SIGILL, and SIGFPE, installed on an alternate stack with sigaltstack so a stack overflow does not defeat the handler. Inside the handler, which must be async signal safe, you capture the thread state and a backtrace and write a minimal report to a pre opened file. Many of a legacy game's worst crashes, the memory lifetime bugs, arrive here as EXC_BAD_ACCESS, so this path is not optional even though the NSException handler feels like the obvious one.

For the most faithful capture you can also register a Mach exception handler, which receives the fault from the kernel before it becomes a signal, but on an older project the simpler signal approach is often the pragmatic choice and covers the cases that matter. The important discipline is installing both the NSException handler and the signal handlers at startup so the two non overlapping paths are both covered. A legacy game that only catches one path will appear to have reporting while silently missing half its crashes, which is worse than knowing you have none.

Symbolication on an aging toolchain

Whatever the age of the project, an Objective C crash report is addresses until symbolicated against the dSYM for that exact build. The complication with legacy games is archival: builds made years ago may not have their dSYMs stored anywhere you can find, and without the dSYM an old crash is unresolvable. Going forward, archive the dSYM for every build by UUID, the same rule as any Apple platform. For builds you still ship, make sure your current pipeline keeps the symbols even if the rest of the toolchain is frozen at an older Xcode version.

Old toolchains add friction but not impossibility. symbolicatecrash and atos still work against an archived dSYM, and a backend can ingest the dSYM and symbolicate server side regardless of how old the build is, as long as the UUID matches. The risk is mismatched or regenerated symbols, which produce confident but wrong function names; verify the dSYM UUID against the binary players run. Treat the symbol archive as the one piece of the legacy build you must preserve carefully, because everything else about debugging an old crash depends on it.

Setting it up with Bugnet

Bugnet gives a legacy Objective C game a modern reporting destination without rewriting the game. Both your NSException handler and your signal handlers send their reports to the same place, and Bugnet stores each as a crash with the stack and the device context attached, so an over release on an old device arrives as a real, symbolicated trace once you supply the dSYM. The in game report button is a low cost addition that lets players describe non crashing problems, which on a long lived game accumulate as the platform moves on under the original code.

Old games tend to have a stable set of recurring crashes, and occurrence grouping turns that into a short, ranked list: the same handful of EXC_BAD_ACCESS sites and unrecognized selector exceptions fold into single issues with counts. That tells you where the limited time you can spend on a legacy title is best invested. Filtering by OS version is especially useful here, because a legacy game's crashes often spike on newer OS releases that the original code never anticipated, and the grouped counts make that drift visible so you fix the failures that the passage of time created.

Retrofitting without a rewrite

The goal with a legacy game is maximum coverage for minimum disturbance. You are not rewriting the memory model or migrating off the old toolchain; you are inserting two handlers at startup and a symbol archival step into the build. Do it in a single, well contained place so the change is easy to review and hard to break, and test both paths deliberately: throw an NSException and force a bad access, and confirm each produces a report that symbolicates. On an old codebase, a small, verified addition beats an ambitious refactor that risks the stability the game already has.

Once reporting is in, a legacy game stops being a black box. You see which old bugs still bite, which crashes the latest OS introduced, and which devices struggle, all without guessing. For a small team maintaining an older title alongside new work, that visibility is the difference between reacting to store reviews and proactively fixing the crashes that matter. The toolchain may be frozen, but the crash data is current, and that is what keeps an aging game shippable as the platform keeps moving.

Legacy Objective C crashes split into exceptions and signals. Catch both, archive every dSYM, and a frozen toolchain still gives current crash data.