// Guards against crashes in the game's own code (each with what is known of // its cause). #include #include #include #include "re/hook.h" #include "re/log.h" namespace game { // cParticleSystem_burningBone::Render (vf04 0x774e10, vtable 0x895b84): // crashed reading the device it was given, which was null (2026-10-16, Esc // pressed while walking; Windows reported sacred.exe+0x374e39). Who renders // it without a device is not known yet (sacredK_crash.txt will tell). With // no device there is nothing to draw. // (device, a 4x4 matrix by value; ret 0x44). struct Matrix4 { float m[16]; }; void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4 transform); RE_HOOK(0x00774e10, BurningBone_Render); void __fastcall BurningBone_Render(void* self, void* edx, void* device, Matrix4 transform) { if (!device) { static bool logged = false; if (!logged) { logged = true; re::Log("guards: burning bone particles rendered without a device, skipped (%p)", _ReturnAddress()); } return; } BurningBone_Render_original(self, edx, device, transform); } // cParticleSystem_grenadeimpact::Render (vf04 0x7b66b0, vtable 0x896cdc; // (device, matrix by value), ret 0x44): Mörsergranate's explosion. Crashed // twice (2026-10-26 00:12 and 22:39, sacred.exe+0x361b54 in the particle // drawer 0x7613e0): a particle's colour is read from the table at // +0x20d0 at row (+0x3d) * 256 + life (+0x2c), and the particle had a life of // over 500000 (it counts down from about 255, at 250..400 per second, by // row: cParticleSystem update 0x763ea0). Particles are 0x40 bytes in the // vector +0x68..+0x6c; alive while life (+0x2c) and size (+0x28) are above 0. // How a particle gets there is not known yet: such particles are switched // off (life 0, the game's dead state) and the first ones logged. constexpr size_t kParticlesBegin = 0x68; constexpr size_t kParticlesEnd = 0x6c; constexpr size_t kParticleSize = 0x40; constexpr size_t kParticleSizeValue = 0x28; // float constexpr size_t kParticleLife = 0x2c; // float, colour column 0..255 constexpr size_t kParticleRow = 0x3d; // u8, colour row constexpr float kColourColumns = 256.0f; constexpr uint8_t kGrenadeImpactRows = 3; // emitter parameter rows at +0x30d0 (0x20 bytes each) constexpr size_t kGrenadeImpactPhase = 0x20a9; // u8: 0 burning, 1 waiting, 2 fading (vf08 0x7b64c0) void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform); RE_HOOK(0x007b66b0, GrenadeImpact_Render); void __fastcall GrenadeImpact_Render(uint8_t* self, void* edx, void* device, Matrix4 transform) { static int logged = 0; uint8_t* begin = *reinterpret_cast(self + kParticlesBegin); uint8_t* end = *reinterpret_cast(self + kParticlesEnd); for (uint8_t* particle = begin; particle && particle < end; particle += kParticleSize) { float& life = *reinterpret_cast(particle + kParticleLife); const float size = *reinterpret_cast(particle + kParticleSizeValue); const uint8_t row = particle[kParticleRow]; if (!(life > 0.0f) || !(size > 0.0f) || (life < kColourColumns && row < kGrenadeImpactRows)) { continue; } if (logged < 5) { ++logged; re::Log("guards: grenade impact particle %d of %d out of range (life %g, row %u, size %g, phase %u), " "switched off", static_cast((particle - begin) / kParticleSize), static_cast((end - begin) / kParticleSize), life, row, size, self[kGrenadeImpactPhase]); } life = 0.0f; } GrenadeImpact_Render_original(self, edx, device, transform); } } // namespace game