Skip to content

ExpandedCrashLogs: fix writeLine call convention (restores minidump generation) - #77

Open
thesammy58 wants to merge 2 commits into
sims3fiend:mainfrom
thesammy58:main
Open

ExpandedCrashLogs: fix writeLine call convention (restores minidump generation)#77
thesammy58 wants to merge 2 commits into
sims3fiend:mainfrom
thesammy58:main

Conversation

@thesammy58

Copy link
Copy Markdown

Two commits against the ExpandedCrashLogs patch. Harry is aware of both issues and okayed a PR (with AI-assisted coding for the fix). He wasn't available to weigh in on specifics, so the judgement calls below are mine and I'm happy to be overruled on any of them. Also, feel free to condense the comments if you want (or I can).

TL;DR - two problems I experienced turned out to be one bug.

The bug

writeLine — vtable slot 0x20 on the crash-log object — takes two arguments, not one. Every call site in TS3.exe pushes two and performs no stack cleanup afterwards, so it's a two-argument __thiscall ending in ret 8. At 0x004d6042 on the EA build (1.69.47.024017):

mov  ecx, [esi+0x510]
mov  edx, [ecx]
push 1                 ; second argument
push eax               ; the string
mov  eax, [edx+0x20]
call eax
mov  ecx, [esi+0x510]  ; no `add esp, 8` — the callee cleans up

The patch called it through the one-argument AcceptString signature at three sites, so each call popped four bytes more than were pushed. openSection (0x18) and closeSection (0x1C) genuinely are one-argument. 0x004d6024 and 0x004d6055 push one and likewise don't clean up, so those were already right.

Why it produced two different symptoms

ESP was left 8 bytes high for the rest of WriteS3SSSectionsInCrashLog, whose epilogue restores ebx/esi/edi with pop (ESP-relative, not EBP-relative) so they came back from the wrong slots. HookedEndOfExceptionReportSections keeps this in esi across that call and never reloads it. That's how this->vtable ended up as 0x8BF18B56: a value that decodes as x86 code bytes, i.e. a stale .text address pulled from the wrong stack slot. /GS doesn't catch it, because the cookie is read via [ebp-0x1c] and is unaffected by the drift.

The resulting fault happens at try-level −1. The game drops it at 0x004d6eb1, immediately before our call site inside the first of two sequential calls in the exception handler:

0x004d6f6c  call 0x004d6cb0   ; write the xcpt…txt sections  <- we're in here
0x004d6f86  call 0x004d6ad0   ; DbgHelp!MiniDumpWriteDump

So the process dies before the minidump is written. The "no minidumps while this patch is enabled" issue was never a separate hooking bug, it turns out it's a downstream consequence of the first one.

It's also invisible from inside the crash log: the header is written from the original exception record before our hook runs, and the game swaps its own filter out at 0x004d6f52, so the second fault is never logged. The missing .mdmp is the only symptom. It can't be seen under a live debugger at all either, since the top-level filter isn't called when one is attached. Postmortem capture and debugging was the only way to catch it.

Evidence

  • Log census across my saved crashes: 8 crashes with the patch enabled on an unfixed build produced zero minidumps; Feb–Aug 2026, three S3SS versions, three different faulting modules, startup and in-world alike. 9 crashes with the patch disabled produced 9. 5 crashes on the fixed build produced 5.
  • Postmortem capture of the unfixed build: (WER LocalDumps, nothing attached) faults at Sims3SettingsSetter+0x491c3, with TS3+0xd6f71 and KERNELBASE!UnhandledExceptionFilter directly beneath it, five instructions short of the minidump call. eax = 0x8BF18B56, matching a Visual Studio capture from days earlier exactly, so the corruption is deterministic.
  • :Isolation: A build carrying only the first commit, with the hook left byte-for-byte as upstream, still produces minidumps. That's what shows the arity fix is the repair and the second commit is only strengthening.

About the second commit

The SEH guard is not what fixes the minidumps. I've kept it because the site is unusually expensive to get wrong: it runs outside any game __try and gates the minidump write, so one bad dereference in a diagnostic patch takes the rest of the diagnostics down with it.

I scoped it to the whole dispatch rather than just the unknown5 read. Unpatched, a fault in the chained callee is fatal in the same place and loses the minidump too, so returning normally is strictly more diagnostics than dying.

It's also worth mentioning that it's a backstop, not a guarantee. __except catches faults, so it covers a bad read or a jump to unmapped memory, but not a jump into valid-but-wrong code. I saw both variants: one capture read the garbage pointer successfully and then jmp'd to it.

Caveats

  • Everything was verified against the EA build 1.69.47.024017, only. The arity is near-certainly identical on Retail and Steam, but I have no way to check those binaries myself. Probably worth a second pair of eyes?
  • I don't know what writeLine's second argument means. The game passes 1 for ordinary content lines everywhere it writes one, including the identical <An exception was encountered …> message at 0x004d6d61 that this patch mirrors, and 0 for two lines in the section at 0x004d61f0. I used 1 here, and the rendered section matches the game's own.

Fixes both the crash inside the hook and the missing native minidumps. This is the root cause; the SEH hardening in the following commit is not what restores dump generation.

The game's crash-log `writeLine` (vtable slot 0x20) takes two arguments, not one. Every call-site in TS3.exe pushes two and performs no stack clean-up afterwards, so it is a two-argument __thiscall that returns with `ret 8` (0x004d6042, 0x004d6d61 and 0x004d61f0 on the EA build). By contrast `openSection` at 0x18 and `closeSection` at 0x1C really do take one argument -- 0x004d6024 and 0x004d6055 push one and likewise do not clean up -- so those call-sites were already correct.

Calling `writeLine` through the one-argument `AcceptString` signature made it pop four bytes more than we pushed. ESP was left skewed for the rest of `WriteS3SSSectionsInCrashLog`, whose epilogue restores ebx/esi/edi with `pop`, so they came back from the wrong stack slots. The hook keeps `this` in esi across that call and never reloads it, which produced the observed `this->vtable == 0x8BF18B56`, which is a value that decodes as x86 code bytes, i.e. a stale .text address recovered from a wrong slot. /GS does not catch this: the cookie is read via [ebp-0x1c], which is unaffected by ESP drift.

The resulting fault killed the process before the game's exception handler reached the minidump writer:

0x004d6f6c  call 0x004d6cb0   ; write the xcpt...txt sections  <- here
0x004d6f86  call 0x004d6ad0   ; DbgHelp!MiniDumpWriteDump

Verified against a repeatable third-party startup crash with an identical code path on every run: with this patch built from upstream and no other change, the crash produces both the expanded xcpt...txt and an xcpt...mdmp; without it, the .mdmp is absent. Post-mortem capture of the unfixed build faults at `Sims3SettingsSetter+0x491c3` with the game's handler on the stack directly below, five instructions short of the minidump call.

Still unverified: the flag's meaning. The game passes 1 for ordinary content lines, including the identical "<An exception was encountered...>" message at 0x004d6d61 that this patch mirrors, and 0 for two lines in the section written by 0x004d61f0. 1 is used here, and the S3SS section renders the same way the game's own sections do.
Simply strengthening, not a 'fix'. The preceding commit is what restores minidump generation. Verified: a build carrying only the writeLine arity fix, with this hook left exactly as upstream, still produces native minidumps.

The reason to harden this particular call anyway is that it is unusually expensive to get wrong. The call-site we replace (0x004d6ebc on the EA build) is reached with the game's own SEH try-level already reset to -1 at 0x004d6eb1, so nothing above us catches anything, and the handler that called us goes on to write the native minidump in a later call at 0x004d6f86. Any exception escaping here therefore costs the .dmp as well as the two finalizing virtual calls at 0x004d6ec1/0x004d6ec5. One bad dereference in a diagnostic patch like this one ironically takes the rest of the diagnostics with it.

The guard covers the whole dispatch rather than only the `this->vtable->unknown5` read. Unpatched, a fault in the chained callee is fatal in exactly the same place and loses the .dmp too, so returning normally is strictly more diagnostics than dying; it costs the tail of the section chain and buys back both the finalized log and the minidump. The trade-off is noted inline; narrow the scope to just the `unknown5` read if that is ever unwanted.

Note this is a backstop, not a guarantee: __except catches faults, so it covers a bad read or a jump to unmapped memory, but not a jump into valid-but-wrong code. That's why the preceding arity fix, not this, is the actual repair.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant