/ src / init.ts
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  }