init.ts
1 import { DEV, MOD_ID } from "$mod"; 2 import noita from "@noita-ts/base"; 3 import ffi from "@noita-ts/ffi"; 4 import GLOBAL_STATS from "@noita-ts/ffi/global_stats"; 5 import debug from "./debug"; 6 7 let ourMemoryAddr; 8 9 const push = ffi.locateStringPush("$stat_streaks"); 10 11 // find the next CALL after the push, which is std::string assignment 12 const call = ffi.scan([0xe8], { at: push }); 13 14 // LEA EDX=>global_stats.prev_best.streaks 15 if (ffi.cast("uint8_t*", call + 5)[0] !== 0x8d) { 16 // ^ this means we already patched stuff 17 // just grab our pointer from the patch 18 ourMemoryAddr = tonumber(ffi.cast("uint32_t*", call + 6)[0])! - 4; 19 } else { 20 ffi.cdef("void* malloc(size_t size);"); 21 22 ourMemoryAddr = tonumber(ffi.cast("uint32_t", ffi.C.malloc(8)))!; 23 24 // remove the check for highest streak being > 0 25 // when choosing if to show the streak in the game over screen at all 26 ffi.patch([0x7c, 0x7d], [0x66, 0x90], { at: push, back: true }); 27 28 // make isVanilla always return true, so that the streaks are counted and always shown 29 // basically a subset of disable mod restrictions 30 const isVanillaCall = ffi.scan([0xe8], { at: push, back: true }); 31 const isVanillaOffset = ffi.cast("int32_t*", isVanillaCall + 1); 32 const isVanillaAddr = isVanillaCall + 5 + isVanillaOffset[0]; 33 34 // just patch the function to instantly return 1 lmao 35 ffi.patchRaw(isVanillaAddr, [ 36 0xb0, // \ 37 0x01, // | mov al, 1 38 0xc3, // ret 39 ]); 40 41 // find and erase the GLOBAL_STATS.highest assignment to its initial value in the `if (mods are present)` block 42 const killerCauseStr = ffi.locateStringPush( 43 "$menugameover_causeofdeath_killer_cause", 44 ); 45 46 // ew 47 const highestAddr = 48 tonumber(ffi.cast("uint32_t", ffi.cast("void*", GLOBAL_STATS)))! + 49 ffi.offsetof("GlobalStats", "highest"); 50 51 const highestLoc = ffi.scan(highestAddr, { at: killerCauseStr, limit: 4096 }); 52 ffi.patch( 53 [0xe8], 54 [ 55 0x83, // \ 56 0xc4, // | add esp, 4 (to clean up the stack from the pushed arg) 57 0x04, // / 58 0x66, // \ 59 0x90, // | nop 60 ], 61 { at: highestLoc + 4 }, 62 ); 63 64 // remove the check for prev_best.streak being >= 1 65 // when choosing if to show the RECORD! thing 66 ffi.patch([0x7e, 0x0c], [0x66, 0x90], { at: push, back: true }); 67 68 const movPatch = (addr: number) => [ 69 0xba, // mov edx, imm32 70 addr & 0xff, 71 (addr >>> 8) & 0xff, 72 (addr >>> 16) & 0xff, 73 (addr >>> 24) & 0xff, 74 0x90, // nop 75 ]; 76 77 // patch both following LEA instructions to load from our memory instead 78 // LEA EDX=>global_stats.prev_best.streaks 79 ffi.patch([0x8d, 0x95], movPatch(ourMemoryAddr + 4), { at: push }); 80 // LEA EDX=>global_stats.session.streaks 81 ffi.patch([0x8d, 0x95], movPatch(ourMemoryAddr), { at: push }); 82 } 83 84 const sessionRender = ffi.cast("uint32_t*", ourMemoryAddr); 85 const prevBestRender = ffi.cast("uint32_t*", ourMemoryAddr + 4); 86 87 // locate the CMP instruction that we dont patch 88 const streakRecordJL = ffi.scan([0x39, 0x85], { at: push, back: true }) + 6; 89 90 // skip the actual check of session.streak >= prev_best.streak 91 const forceRecord = () => ffi.patchRaw(streakRecordJL, [0x66, 0x90]); 92 // undo the above, duh 93 const restoreRecord = () => ffi.patchRaw(streakRecordJL, [0x7c, 0x04]); 94 95 noita.on("PlayerSpawned", () => { 96 if (ModSettingGet(MOD_ID + ".streak") !== undefined) { 97 return; 98 } 99 100 // ideally we would scan session stat files for largest death streak retroactively, 101 // but apparently you can't tell if the run was a win or not?.. nolla.. 102 const endroomWins = GLOBAL_STATS.KEY_VALUE_STATS.get("progress_ending0") ?? 0; 103 const altarWins = GLOBAL_STATS.KEY_VALUE_STATS.get("progress_ending1") ?? 0; 104 105 if (endroomWins + altarWins === 0) { 106 ModSettingSet(MOD_ID + ".streak", GLOBAL_STATS.global.death_count); 107 ModSettingSet(MOD_ID + ".worst", GLOBAL_STATS.global.death_count); 108 } 109 }); 110 111 noita.on("PlayerDied", () => { 112 // if you won 113 if ( 114 GameHasFlagRun("ending_game_completed") || 115 MagicNumbersGetValue("DEBUG_ALWAYS_COMPLETE_THE_GAME") != "0" 116 ) { 117 // the negative streak is lost 😂 118 ModSettingSet(MOD_ID + ".streak", 0); 119 120 // let the game render its streak 121 sessionRender[0] = GLOBAL_STATS.session.streak; 122 prevBestRender[0] = GLOBAL_STATS.highest.streak; 123 restoreRecord(); 124 return; 125 } 126 127 let streak = (ModSettingGet(MOD_ID + ".streak") || 0) as number; 128 streak = streak + 1; 129 ModSettingSet(MOD_ID + ".streak", streak); 130 131 let worst = (ModSettingGet(MOD_ID + ".worst") || 0) as number; 132 if (streak >= worst) { 133 // this follows game behaviour with streaks, we show RECORD! of number is >= previous best, 134 // and we update the saved value, but show the previous one which is one lower than the new pb 135 ModSettingSet(MOD_ID + ".worst", streak); 136 forceRecord(); 137 } else { 138 // RECORD! is never shown when you just died, so we keep the default behaviour here 139 restoreRecord(); 140 } 141 142 // we only do this for Noita Utility Box live stats tool to show it in the overlay 143 if (ModSettingGet(MOD_ID + ".set-negative")) { 144 GLOBAL_STATS.session.streak = -streak; 145 } 146 147 // and render our negative streak 148 sessionRender[0] = -streak; 149 prevBestRender[0] = -worst; 150 }); 151 152 if (DEV) { 153 debug(sessionRender, prevBestRender); 154 }