Compare commits

...
2 Commits
Author SHA1 Message Date
Gaël 9bb9797271 Adding patch notes from git, UI, and loading files from the main install path properly.
Also fixed:
* flying wands not pathing properly.
* Lantern particles offset
* Wand fire offset
* Water spray from flask
2026-10-22 14:18:49 +02:00
Gaël 06bb712a46 adding audio_host project for hooking noita FMOD 2026-10-22 13:44:47 +02:00
21 changed files with 1792 additions and 134 deletions

No files matched your search

+8
View File
@@ -67,12 +67,20 @@ target_include_directories(ngblosc INTERFACE ${CMAKE_SOURCE_DIR}/third_party/c-b
add_executable(noita_gpu src/app/main.cpp src/audio/fmod_audio.cpp)
target_link_libraries(noita_gpu PRIVATE ngspawn ngblosc nggpulua Vulkan::Vulkan)
#for changelog
# the short git hash of the sources at configure time (the GIF metadata's "build", docs/GIF_METADATA.md)
execute_process(COMMAND git rev-parse --short HEAD WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE NG_GIT_HASH OUTPUT_STRIP_TRAILING_WHITESPACE ERROR_QUIET)
if(NOT NG_GIT_HASH)
set(NG_GIT_HASH "dev")
endif()
target_compile_definitions(noita_gpu PRIVATE NG_BUILD="${NG_GIT_HASH}")
# the release notes of the menu (src/app/menu.h): the git log as "hash|date|subject" lines, beside the executable and in the demo folder
execute_process(COMMAND git log -n 400 --date=short "--pretty=format:%h|%ad|%s" WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} OUTPUT_VARIABLE NG_GIT_LOG ERROR_QUIET)
file(WRITE ${CMAKE_BINARY_DIR}/release_notes.txt "${NG_GIT_LOG}\n")
add_custom_command(TARGET noita_gpu POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_BINARY_DIR}/release_notes.txt $<TARGET_FILE_DIR:noita_gpu>/release_notes.txt
COMMAND ${CMAKE_COMMAND} -E copy_if_different ${CMAKE_BINARY_DIR}/release_notes.txt ${CMAKE_SOURCE_DIR}/demo/release_notes.txt)
if(MSVC)
target_compile_options(noita_gpu PRIVATE /W4 /permissive- /bigobj)
endif()
+10 -3
View File
@@ -45,6 +45,8 @@ layout(std430, set = 0, binding = 23) buffer Ctrl {
ivec4 hand1; // host: held wand: texel offset, w, h
vec4 hand2; // host: held wand: pivot
ivec4 burnN; // rigid_burn.comp: x the burnt pixels; y: the player's respawns, z: saved by SAVING_GRACE, w: an extra life used (the host reads and clears them)
ivec4 burnEv[256]; ivec4 reactN; ivec4 reactEv[64];
ivec4 extra; // host: x = 1: a dead player stays dead where it fell (the game over of the menus) instead of respawning
} ctrl;
#include "hit_liquid.glsl"
@@ -303,9 +305,14 @@ void main() {
if (hp <= 0.0 && (e.ex8.x & 16) != 0 && hpBeforeHit > 0.04) { hp = 0.04; atomicAdd(ctrl.burnN.z, 1); }
else if (hp <= 0.0 && (ents[id].ex8.x & 131072) != 0) { hp = min(4.0, e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0); atomicAnd(ents[id].ex8.x, ~131072); atomicAdd(ctrl.burnN.w, 1); }
if (hp <= 0.0 && (e.ex8.x & 32768) == 0) { // respawn at the spawn point (a polymorphed body is not: the host ends the polymorph, 32768)
pos = vec2(float(e.x.z), float(e.x.w)); vel = vec2(0.0); ip = ivec2(floor(pos));
hp = e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0;
atomicAdd(ctrl.burnN.y, 1);
if (ctrl.extra.x != 0) { // the game over (menu.h): the body stays where it fell, dead - no controls, counted once for the host
if ((flags & ST_DEAD) == 0) atomicAdd(ctrl.burnN.y, 1);
flags |= ST_DEAD; hp = 0.0; target = 0.0; want_jump = false; thrust = false;
} else {
pos = vec2(float(e.x.z), float(e.x.w)); vel = vec2(0.0); ip = ivec2(floor(pos));
hp = e.aux.x > 0 ? float(e.aux.x) * 0.001 : 4.0;
atomicAdd(ctrl.burnN.y, 1);
}
}
} else if (kind == 1 || kind == 2) { // ---- creatures ----
// the target of the creature: the player; a charmed creature (game effect CHARM) fights the nearest creature of another herd instead
+1
View File
@@ -91,6 +91,7 @@ void main() {
if (mirror) hl.y = -hl.y;
hand = root + vec2(c * hl.x - s * hl.y, s * hl.x + c * hl.y);
}
if (ctrl.hand1.w == 1) hand += vec2(mirror ? -0.5 : 0.5, 0.5); // flask entity transform, matching the host spray origin
{ // the item's recoil (00b51670): hand2.z px back along the aim, turned muzzle-up by hand0.w radians (the arm keeps the aim)
const float ia = ang + (mirror ? ctrl.hand0.w : -ctrl.hand0.w);
const float ic = cos(ia), is = sin(ia);
+11 -10
View File
@@ -62,7 +62,8 @@ void displaced_particle(uint id, FPart p) {
fparts[id].c.w = p.c.w + 1u;
return;
}
const float g = 0.1667;
const bool spray = (p.c.w & 0x200000u) != 0u;
const float g = spray ? PART_GRAV : 0.1667;
vec2 target = pos + vel;
vel.y += g * (kind == 1u ? 0.25 : 1.0);
if (kind == 1u && vel.y > 0.0) kind = 2u;
@@ -81,14 +82,14 @@ void displaced_particle(uint id, FPart p) {
uint m = cell_mat(cells[q.y * pc.gw + q.x]);
if (m != 0u) {
uint f = mflags(m);
if ((f & F_STATIC) != 0u) { dead = true; break; }
if ((f & F_LIQUID) != 0u) {
if ((f & F_STATIC) != 0u && !(spray && kind == 1u)) { dead = true; break; }
if ((f & F_LIQUID) != 0u || (spray && (f & F_STATIC) != 0u)) {
if (kind == 2u) { dead = true; break; } // mode 0 stops at liquid (and powder)
float eff = (vel.y > -0.83 || (p.c.w & 0x100000u) != 0u) ? 0.0 : dens; // mode 1: effective density 0 (an upward speed below 50 px/s, or a draw at creation) unless moving up fast; it must be lighter than the target
if (!(eff < mdensity(m)) || budget == 0u) { dead = true; break; }
budget -= 1u;
}
}
} else if (spray) kind = 2u;
} else if (spray) kind = 2u; // 00712390: leaving material ends the mode-1 penetration.
cur = nx;
}
if (!dead && int(mlen) <= 60) { // the target cell itself
@@ -98,15 +99,15 @@ void displaced_particle(uint id, FPart p) {
uint m = cell_mat(cells[q.y * pc.gw + q.x]);
if (m != 0u) {
uint f = mflags(m);
if ((f & F_STATIC) != 0u) dead = true;
else if ((f & F_LIQUID) != 0u) {
if ((f & F_STATIC) != 0u && !(spray && kind == 1u)) dead = true;
else if ((f & F_LIQUID) != 0u || (spray && (f & F_STATIC) != 0u)) {
if (kind == 2u) dead = true;
else {
float eff = (vel.y > -0.83 || (p.c.w & 0x100000u) != 0u) ? 0.0 : dens;
if (!(eff < mdensity(m)) || budget == 0u) dead = true; else budget -= 1u;
}
}
}
} else if (spray) kind = 2u;
} else if (spray) kind = 2u;
if (!dead) cur = target;
}
} else if (!dead) cur = target; // beyond the 60 probes the end point is taken as it is
@@ -116,7 +117,7 @@ void displaced_particle(uint id, FPart p) {
kind = 3u; // no place yet: it keeps trying
}
fparts[id].a = vec4(pos, vel);
fparts[id].c = uvec4(cw, kind, budget, (p.c.w & 0x100000u) | ((p.c.w & 0xFFFFFu) + 1u));
fparts[id].c = uvec4(cw, kind, budget, (p.c.w & 0x300000u) | ((p.c.w & 0xFFFFFu) + 1u));
ivec2 q = ivec2(floor(pos)) - ivec2(pc.view_x, pc.view_y);
if (q.x >= 0 && q.y >= 0 && q.x < pc.gw && q.y < pc.gh) {
vec3 col = cell_colour(cw);
+7
View File
@@ -33,6 +33,13 @@ void main() {
if (old == 0u) { ext[ci] = vec2(0.0); birth[ci] = birth_frame(); }
else if ((r.c.w & 2u) == 0u && (mflags(cell_mat(old)) & (F_GAS | F_LIQUID)) != 0u && (mflags(cell_mat(old)) & (F_STATIC | F_SAND)) == 0u && atomicCompSwap(cells[ci], old, nc) == old) { ext[ci] = vec2(0.0); birth[ci] = birth_frame(); }
}
else if (r.c.y == 7u) { // PotionSystem 00bfdd10: mode 1, density zero, 20-cell penetration budget.
const int ps = alloc_part();
if (ps < 0) return;
fparts[ps].a = r.a;
fparts[ps].b = vec4(r.b.x, 1.0, uintBitsToFloat(birth_frame()), 0.0);
fparts[ps].c = uvec4(r.c.z | (((hash3(i, uint(pc.frame), 64u) >> 16) & 0xFFu) << 16), 1u, 20u, 0x300000u);
}
else if (r.c.y == 6u) emit_displaced(r.a.xy, r.a.zw, r.c.z | (((hash3(i, uint(pc.frame), 64u) >> 16) & 0xFFu) << 16), 2u, birth_frame()); // GameCreateParticle (007b3120 -> 00716af0): a cell of the material handed to the particle system like a displaced one (AddAsParticle 007168c0): it falls and goes back into the grid, waiting for a free place
else emit_material_particle(r.a.xy, r.a.zw, r.b.x, r.b.y, r.c.x, int(r.c.z));
}
+8 -2
View File
@@ -27,7 +27,7 @@ layout(std430, set = 0, binding = 39) readonly buffer EntPrev { vec4 prevpos[];
layout(std430, set = 0, binding = 26) readonly buffer Overlay { uint overlay[]; }; // CPU-drawn HUD (ARGB, output pixels)
struct FLight { vec4 a; vec4 b; }; // x, y, radius (world cells), strength / rgb
layout(std430, set = 0, binding = 23) readonly buffer Ctrl { ivec4 inp; ivec4 inp2; ivec4 inp3; vec4 outp; vec4 outp2; vec4 view; vec4 hand0; ivec4 hand1; vec4 hand2; } ctrl;
layout(std430, set = 0, binding = 23) readonly buffer Ctrl { ivec4 inp; ivec4 inp2; ivec4 inp3; vec4 outp; vec4 outp2; vec4 view; vec4 hand0; ivec4 hand1; vec4 hand2; ivec4 burnN; ivec4 burnEv[256]; ivec4 reactN; ivec4 reactEv[64]; ivec4 extra; } ctrl; // extra.yzw: the Options' brightness, contrast, gamma x 10000 (main.cpp)
layout(std430, set = 0, binding = 38) readonly buffer FxLights { uint lcount; uint lpad[3]; FLight lights[512]; }; // lights of projectiles and explosions this frame
layout(std430, set = 0, binding = 44) readonly buffer Sky { uint sky[]; }; // src/world/sky.h
@@ -423,7 +423,7 @@ void main() {
if (p.x >= pc.ow || p.y >= pc.oh) return;
ivec2 cp = clamp(ivec2(floor(vec2(p + so + vis) / scl)), ivec2(0), ivec2(pc.gw - 1, pc.gh - 1));
vec3 rgb = unpackUnorm4x8(wcell[cp.y * pc.gw + cp.x].x).rgb; // (only the colour: the light is read for the player's pixels)
if (ctrl.inp2.y != 0 && (ents[0].st.w & 8) == 0) {
if (ctrl.inp2.y != 0 && (ents[0].st.w & 8) == 0) { // (dead: the ragdoll of 00abb7d0 is the body, main.cpp onCreatureDeath(0, true))
vec2 P = ents[0].a.xy;
const vec2 pv = prevpos[0].xy;
if (distance(pv, P) < 10.0) P = mix(pv, P, ctrl.view.z);
@@ -445,6 +445,12 @@ void main() {
rgb += vec3(0.7, 0.1, 0.0) * a * lh;
}
}
if (ctrl.extra.y != 0 || ctrl.extra.z != 0 || ctrl.extra.w != 0) { // the Options' sliders (post_final.frag: + brightness, (c - 0.5) x contrast + 0.5, pow(c, gamma), clamped; 1 + the slider for the last two)
rgb += float(ctrl.extra.y) * 1e-4;
rgb = (rgb - 0.5) * (1.0 + float(ctrl.extra.z) * 1e-4) + 0.5;
rgb = pow(max(rgb, vec3(0.0)), vec3(1.0 + float(ctrl.extra.w) * 1e-4));
rgb = clamp(rgb, 0.0, 1.0);
}
{ // the picture without the HUD for the replay recorder (the game's GIFs have no UI): clip_half.comp / main.cpp copy it
const uvec3 q0 = uvec3(clamp(rgb, 0.0, 1.0) * 255.0 + 0.5);
clipraw[p.y * pc.ow + p.x] = 0xFF000000u | (q0.r << 16) | (q0.g << 8) | q0.b;
+1 -1
View File
@@ -62,7 +62,7 @@ void main() {
ivec4 h0 = entinst[4u * id], hi = entinst[4u * id + 1u], hw = entinst[4u * id + 2u];
Ent e = ents[h0.x];
gKey = (uint(clamp(h0.w + 32768, 0, 65535)) << 16) | (65535u - (id & 65535u));
if ((e.st.w & (8 | 4096)) != 0) return; // dead, or its sprites switched off (4096: a creature, main.cpp on_components_enabled)
if ((e.st.w & (8 | 4096)) != 0) return; // dead (the host draws the ragdoll), or its sprites switched off (4096: a creature, main.cpp on_components_enabled)
if (e.st.x != 4 && (e.ex8.x & 8192) != 0) return;
{ // the hit flash: creatures, the player and physics items (a worm segment, kind 0, keeps its root slot in ex19.w)
const int fc = int(e.ex19.w + 0.5);
+2 -2
View File
@@ -142,7 +142,7 @@ std::vector<PathNode> search_pass(const PathGrid& g, float sx, float sy, const S
else if (g.valid(snx, sny - d) && !g.solid(snx, sny - d)) { sny -= d; moved = true; }
}
if (!moved) return out;
} else if (!p.can_fly && !g.solid(snx, sny + 1) && !(g.at(snx, sny + 1) & kNodeLiquid) && !(g.at(snx, sny) & kNodeLiquid)) {
} else if (!p.can_fly && !p.flyer && !g.solid(snx, sny + 1) && !(g.at(snx, sny + 1) & kNodeLiquid) && !(g.at(snx, sny) & kNodeLiquid)) { // (a flyer starts where it is, in the air: 00861e00 +0x24 at the start)
for (int d = 1; d <= 3; ++d) {
if (!g.valid(snx, sny + d) || g.solid(snx, sny + d) || (g.at(snx, sny + d) & kNodeLiquid)) break;
sny += 1;
@@ -339,7 +339,7 @@ std::vector<PathNode> search(const PathGrid& g, float sx, float sy, const Search
bool budget = false;
std::vector<PathNode> partial;
if (p.can_walk || p.can_dive || p.can_swim_on_surface) {
SearchParams q = p; q.can_fly = false; q.can_jump = false;
SearchParams q = p; q.can_fly = false; q.can_jump = false; q.flyer = p.can_fly;
std::vector<PathNode> r = search_pass(g, sx, sy, q, budget);
if (!r.empty() && !budget) return r;
if (budget) partial = std::move(r);
+1
View File
@@ -54,6 +54,7 @@ enum class Goal { None, Hunt, RandomMove, Goto, MoveNear, Burning, Away, Escape,
struct SearchParams {
bool can_fly = false, can_walk = true, can_jump = false, can_dive = false, can_swim_on_surface = false;
bool flyer = false; // (set by search(): the creature can fly - 00861e00 decides the start projection on the original can_fly, before the passes turn it off)
int clearance_required = 0; // space_required / 4 (00bca030: input +0xcc)
float cost_of_flying = 20.0f;
int depth_max = 50; // nodes along the path (AnimalAI pathfinding_max_depth_has_target / _no_target)
+21 -3
View File
@@ -49,6 +49,23 @@ struct Icon {
bool ok() const { return w > 0; }
};
// data/shaders/imgui_potion_icon.frag: the upper part retains the glass colour.
inline int potion_fill_row(int height, float fraction) {
return std::clamp((int)std::ceil(height * (0.85f - 0.67f * std::max(0.1f, fraction)) - 0.5f), 0, height);
}
inline Icon potion_icon(const Icon& base, uint32_t material, int fill_row) {
Icon out = base;
for (int y = 0; y < out.h; ++y) for (int x = 0; x < out.w; ++x) {
uint32_t& p = out.px[(size_t)y * out.w + x];
auto channel = [&](int shift) {
const float tint = y >= fill_row ? ((material >> shift) & 255) / 255.0f : 1.0f;
return (uint32_t)(((p >> shift) & 255) * tint * 0.85f);
};
p = (p & 0xFF000000u) | channel(16) << 16 | channel(8) << 8 | channel(0);
}
return out;
}
inline Icon load_icon(const ng::Wak& wak, const std::string& path) {
Icon ic;
std::vector<uint8_t> raw;
@@ -285,7 +302,7 @@ struct State {
struct WandInfo { const Icon* icon = nullptr; std::string name; int cards = 0, capacity = 0, uses = -1; }; // uses: the uses left of its first limited spell (-1 none)
std::vector<WandInfo> wands; // inventory slots
int active_wand = -1;
struct ItemInfo { const Icon* icon = nullptr; float frac = 1.0f; std::string name; bool empty = false; }; // an item of the quick inventory (flask): icon, how full it is
struct ItemInfo { const Icon* icon = nullptr; std::string name; bool empty = false; }; // flask icon includes its liquid level
std::vector<ItemInfo> items; // slots 5-8
int active_slot = -1; // highlighted slot (0-7) when it is an item
std::vector<const Icon*> spell_icons; // spells of the active wand, in deck order
@@ -322,6 +339,7 @@ struct State {
bool drag_on = false; const Icon* drag_icon = nullptr; int drag_bg = -1; int drag_x = 0, drag_y = 0; // the item on the cursor
std::vector<TipLine> tip; // hover information
int tip_x = 0, tip_y = 0;
std::string cursor_material;
// ---- the text sprites of the world (shop prices: SpriteComponent is_text_sprite, font_pixel_white), top-left corner in output pixels, scale = output pixels per world pixel ----
std::vector<InvText> world_texts;
// ---- overlays drawn over everything (the information box of a wand in the world, the new-wand choice): style 2 the dark information box, 3 the orange banner ----
@@ -434,8 +452,6 @@ inline void draw(Canvas& cv, const Assets& as, const State& s) {
if (i >= 4 && i - 4 < (int)s.items.size() && !s.items[i - 4].empty) {
const State::ItemInfo& it = s.items[i - 4];
if (it.icon && it.icon->ok()) cv.blit_centered(it.icon->px.data(), it.icon->w, it.icon->h, x + slot / 2, y0 + slot / 2, std::max(1, std::min(ui, (slot * 3 / 4) / std::max(it.icon->w, 1))));
cv.rect(x + 2 * ui, y0 + slot - 3 * ui, slot - 4 * ui, 2 * ui, rgba(0, 0, 0, 170)); // how full the flask is
cv.rect(x + 2 * ui, y0 + slot - 3 * ui, (int)((slot - 4 * ui) * std::clamp(it.frac, 0.0f, 1.0f)), 2 * ui, rgba(110, 170, 230));
}
if (i < 4 && i < (int)s.wands.size() && s.wands[i].icon && s.wands[i].icon->ok()) {
const Icon& ic = *s.wands[i].icon;
@@ -443,6 +459,8 @@ inline void draw(Canvas& cv, const Assets& as, const State& s) {
if (s.wands[i].uses >= 0) cv.text(x + 2 * ui, y0 + 2 * ui, std::to_string(s.wands[i].uses), rgba(255, 255, 255), std::max(1, ts)); // the first limited spell's uses left
}
}
if (!s.cursor_material.empty() && !s.inv_open && !s.paused)
cv.text(x0 + 8 * pitch + 5 * ui, y0 + (slot - 7 * ts) / 2, s.cursor_material, white, ts);
// spells of the active wand, under the quick inventory
const int sy = y0 + slot + 3 * ui;
for (size_t i = 0; i < s.spell_icons.size() && !s.inv_open; ++i) {
+674 -106
View File
File diff suppressed because it is too large. Load diff
+765
View File
@@ -0,0 +1,765 @@
// The game's menus, drawn into the HUD canvas: the title screen (006e3410 with DAT_0120761b off), the pause menu (the same with the help image), the new game
// panel with the game modes (006ceb00 / 006378a0: "normal" and "dailyrun" built in, then every mod with is_game_mode in its mod.xml), the daily run's seed from
// the game's backend (0084c6f0: GET https://takapuoli.noitagame.com/callback/ -> "version;daily seed;daily practice seed;1;"), the options (006d5620: the tabs
// General / Graphics / Audio / Input / Streaming / Mod settings), the release notes (0084d020 reads _release_notes.txt beside the game: here release_notes.txt
// beside our executable, written from the git log at configure time - every entry is a commit, its hash the version), the credits (data/credits.txt) and the
// game over screen with the run's stats (006e51e0: the cause of death, then the stats box with the bests, New Game / Save death replay / Progress / Quit).
// Layout in GUI units: `gs` window pixels each (the game's GUI scale: the window height / 360, so a 1440p window draws at 4 like the game).
#pragma once
#include <algorithm>
#include <atomic>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <ctime>
#include <functional>
#include <map>
#include <set>
#include <string>
#include <thread>
#include <vector>
#include "app/hud.h"
#include "core/wak.h"
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <wininet.h>
#pragma comment(lib, "wininet.lib")
namespace menu {
// ---------------------------------------------------------------- assets ----------------------------------------------------------------
struct Assets {
hud::Icon logo, gameOver, gameCompleted, help, p9, p9gray, tab, tabHover, tabSel, bannerSel, grass, bannerNormalBg, bannerNormalFg, bannerDailyBg, bannerDailyFg,
mountains1, mountains2, layer1, layer2, clouds1, stars[7], cursor, gridBox, gridBoxUnknown, gridNew, iconUnknown;
std::vector<uint32_t> sky; int skyW = 0, skyH = 0; // weather_gfx/parallax_colors_main_menu.bmp: a column is the sky's gradient from the top (row 0) down
std::string credits;
bool loaded = false;
static bool load_bmp(const std::vector<uint8_t>& raw, std::vector<uint32_t>& px, int& w, int& h) { // 24-bit uncompressed, bottom-up
if (raw.size() < 54 || raw[0] != 'B' || raw[1] != 'M') return false;
const uint32_t off = *reinterpret_cast<const uint32_t*>(&raw[10]);
const int32_t bw = *reinterpret_cast<const int32_t*>(&raw[18]), bh = *reinterpret_cast<const int32_t*>(&raw[22]);
const uint16_t bpp = *reinterpret_cast<const uint16_t*>(&raw[28]);
if (bpp != 24 || bw <= 0 || bh == 0) return false;
const int ah = std::abs(bh), stride = (bw * 3 + 3) & ~3;
if (off + (size_t)stride * ah > raw.size()) return false;
w = bw; h = ah; px.assign((size_t)w * h, 0);
for (int y = 0; y < h; ++y) {
const uint8_t* row = &raw[off + (size_t)(bh > 0 ? h - 1 - y : y) * stride];
for (int x = 0; x < w; ++x) px[(size_t)y * w + x] = 0xFF000000u | (uint32_t)row[x * 3 + 2] << 16 | (uint32_t)row[x * 3 + 1] << 8 | row[x * 3];
}
return true;
}
void load(const ng::Wak& wak) {
auto L = [&](const char* p) { return hud::load_icon(wak, p); };
logo = L("data/ui_gfx/pause_menu/noita_logo.png"); gameOver = L("data/ui_gfx/game_over_menu/game_over.png"); gameCompleted = L("data/ui_gfx/game_over_menu/game_completed.png");
help = L("data/ui_gfx/pause_menu/help_keyboardmouse.png"); p9 = L("data/ui_gfx/decorations/9piece0.png"); p9gray = L("data/ui_gfx/decorations/9piece0_gray.png");
tab = L("data/ui_gfx/decorations/tab.png"); tabHover = L("data/ui_gfx/decorations/tab_hovered.png"); tabSel = L("data/ui_gfx/decorations/tab_selected.png");
bannerSel = L("data/ui_gfx/game_mode/banner_selected_overlay.png"); grass = L("data/ui_gfx/game_mode/background_grass.png");
bannerNormalBg = L("data/ui_gfx/game_mode/banner_normal_background.png"); bannerNormalFg = L("data/ui_gfx/game_mode/banner_normal_overlay.png");
bannerDailyBg = L("data/ui_gfx/game_mode/banner_dailyrun_background.png"); bannerDailyFg = L("data/ui_gfx/game_mode/banner_dailyrun_overlay.png");
mountains1 = L("data/weather_gfx/parallax_mountains_01.png"); mountains2 = L("data/weather_gfx/parallax_mountains_02.png");
layer1 = L("data/weather_gfx/parallax_mountains_layer_01.png"); layer2 = L("data/weather_gfx/parallax_mountains_layer_02.png"); clouds1 = L("data/weather_gfx/parallax_clounds_01.png");
for (int i = 0; i < 7; ++i) stars[i] = L(("data/weather_gfx/star_00" + std::to_string(i + 1) + ".png").c_str());
cursor = L("data/ui_gfx/mouse_cursor.png");
gridBox = L("data/ui_gfx/progress_menu/grid_box.png"); gridBoxUnknown = L("data/ui_gfx/progress_menu/grid_box_unknown.png"); // (006dfe40: the Progress screen's cells)
gridNew = L("data/ui_gfx/progress_menu/grid_highlight_new.png"); iconUnknown = L("data/ui_gfx/progress_menu/icon_unknown.png");
std::vector<uint8_t> raw;
if (wak.read("data/weather_gfx/parallax_colors_main_menu.bmp", raw)) load_bmp(raw, sky, skyW, skyH);
if (wak.read("data/credits.txt", raw)) credits.assign(raw.begin(), raw.end());
loaded = true;
}
};
// ---------------------------------------------------------------- settings ----------------------------------------------------------------
// The options of the Options screen, kept in <save dir>/settings.txt (key=value). The engine reads them where they apply (main.cpp).
struct KeyBind { const char* id; const char* name; int key; };
struct Settings {
int window_mode = 0; // 0 windowed, 1 borderless fullscreen
bool vsync = true, cursor = false, replay_recorder = false, damage_numbers = true, pause_unfocused = true, click_switches_item = false, hover_info_at_mouse = false;
float music = 1.0f, sfx = 1.0f, screenshake = 1.0f;
// 006d5620's sliders: brightness -0.09 .. 0.09, contrast -0.12 .. 0.2, gamma -0.32 .. 0.695 (all 0 by default: post_final.frag adds the brightness, scales about
// 0.5 by 1 + contrast, raises to 1 + gamma), the cosmetic particle coefficient 0.05 .. 1 (the count of every cosmetic emission)
float brightness = 0.0f, contrast = 0.0f, gamma = 0.0f, cosmetic_particles = 1.0f;
static constexpr float kBrightMin = -0.09f, kBrightMax = 0.09f, kContrastMin = -0.12f, kContrastMax = 0.2f, kGammaMin = -0.32f, kGammaMax = 0.695f, kCosmeticMin = 0.05f;
bool online = true, streamer_mode = false; // (streamer mode: the game's Twitch integration switch - 006ceb00 opens the Streaming tab when it goes on)
std::vector<KeyBind> keys = {{"left", "Move left", 'A'}, {"right", "Move right", 'D'}, {"up", "Levitate / jump", 'W'}, {"down", "Move down", 'S'}, {"kick", "Kick", 'F'},
{"interact", "Interact / pick up", 'E'}, {"inventory", "Inventory", VK_TAB}, {"drop", "Drop item", 'Q'}, {"pause", "Pause", 'P'}};
int key(const char* id) const { for (const KeyBind& k : keys) if (!std::strcmp(k.id, id)) return k.key; return 0; }
std::string path;
void load(const std::string& p) {
path = p;
FILE* f = std::fopen(p.c_str(), "rb");
if (!f) return;
char line[512];
while (std::fgets(line, sizeof line, f)) {
std::string s(line); while (!s.empty() && (s.back() == '\n' || s.back() == '\r' || s.back() == ' ')) s.pop_back();
const size_t eq = s.find('=');
if (eq == std::string::npos) continue;
const std::string k = s.substr(0, eq), v = s.substr(eq + 1);
auto b = [&](const char* n, bool& dst) { if (k == n) dst = v == "1"; };
auto fl = [&](const char* n, float& dst) { if (k == n) dst = (float)std::atof(v.c_str()); };
if (k == "window_mode") window_mode = std::atoi(v.c_str());
b("vsync", vsync); b("cursor", cursor); b("replay_recorder", replay_recorder); b("damage_numbers", damage_numbers); b("pause_unfocused", pause_unfocused);
b("click_switches_item", click_switches_item); b("hover_info_at_mouse", hover_info_at_mouse); b("online", online); b("streamer_mode", streamer_mode);
fl("music", music); fl("sfx", sfx); fl("screenshake", screenshake); fl("brightness", brightness); fl("contrast", contrast); fl("gamma", gamma); fl("cosmetic_particles", cosmetic_particles);
if (k.compare(0, 4, "key_") == 0) for (KeyBind& kb : keys) if (k.substr(4) == kb.id) kb.key = std::atoi(v.c_str());
}
std::fclose(f);
brightness = std::clamp(brightness, kBrightMin, kBrightMax); contrast = std::clamp(contrast, kContrastMin, kContrastMax); gamma = std::clamp(gamma, kGammaMin, kGammaMax);
cosmetic_particles = std::clamp(cosmetic_particles, kCosmeticMin, 1.0f);
}
void save() const {
if (path.empty()) return;
FILE* f = std::fopen(path.c_str(), "wb");
if (!f) return;
std::fprintf(f, "window_mode=%d\nvsync=%d\ncursor=%d\nreplay_recorder=%d\ndamage_numbers=%d\npause_unfocused=%d\nclick_switches_item=%d\nhover_info_at_mouse=%d\nonline=%d\nstreamer_mode=%d\n",
window_mode, vsync, cursor, replay_recorder, damage_numbers, pause_unfocused, click_switches_item, hover_info_at_mouse, online, streamer_mode);
std::fprintf(f, "music=%.3f\nsfx=%.3f\nscreenshake=%.3f\nbrightness=%.3f\ncontrast=%.3f\ngamma=%.3f\ncosmetic_particles=%.3f\n", music, sfx, screenshake, brightness, contrast, gamma, cosmetic_particles);
for (const KeyBind& k : keys) std::fprintf(f, "key_%s=%d\n", k.id, k.key);
std::fclose(f);
}
};
// ---------------------------------------------------------------- the run, its stats, the game modes ----------------------------------------------------------------
struct GameMode { std::string id, name, desc, banner_bg, banner_fg; bool mod = false; };
struct RunStats { int gold = 0; double playtime = 0; int depth = 0, places = 0, kills = 0, maxhp = 0, items = 0; }; // (depth in metres: the deepest point / 10)
struct ProgressItem { std::string id, icon, name, desc; bool known = false, is_new = false; int kills = 0, killed_by = 0; }; // a perk, a spell or an enemy of the Progress screen (006dfe40)
struct ModInfo { std::string id, name, desc; bool enabled = false, game_mode = false; }; // a folder of mods/ (the Mods screen: 00b84f0's list, enabled in order)
struct Context { // what main.cpp knows, given to the menu every frame
int w = 0, h = 0, mx = 0, my = 0, wheel = 0;
bool click = false, lmb = false, escape = false, has_run = false, mods_used = false, dead = false;
int key_pressed = 0; // a key that went down this frame (the Input tab's rebinding)
std::string build, run_mode, location, death_cause;
uint32_t seed = 0;
const std::string* release_notes = nullptr; // the text beside the executable
const std::vector<GameMode>* modes = nullptr;
const std::vector<std::string>* mods = nullptr; // the enabled mods
RunStats stats, best; int total_deaths = 0, total_wins = 0;
Settings* settings = nullptr;
const std::vector<ProgressItem>* perks = nullptr; const std::vector<ProgressItem>* actions = nullptr; const std::vector<ProgressItem>* enemies = nullptr; // the Progress screen
int secrets_found = 0; bool ending0 = false, ending1 = false; // (the progress_* flags: the secret_* / progress_* ones found, the two endings)
std::vector<ModInfo>* all_mods = nullptr; // every mod in mods/: the Mods screen toggles and reorders them, Action::ApplyMods writes the list
bool replay_available = false; // the replay recorder holds frames: the game over's "Save death replay" opens the clip editor
bool suspended = false; // the clip editor is up over the menu: nothing drawn, nothing read
std::function<std::string(const std::string&)> tr; // "$menu_continue" -> the game's text (common.csv), the key itself when unknown
};
enum class Action { None, Continue, StartGame, Quit, SaveQuit, ApplySettings, ResetProgress, SaveReplay, ApplyMods, OpenModFolder, RestartMods, ProgressSeen };
struct Result { Action action = Action::None; std::string mode_id; uint32_t seed = 0; bool daily = false, daily_practice = false; };
// ---------------------------------------------------------------- the daily seed ----------------------------------------------------------------
// 0084c6f0: HTTPS GET takapuoli.noitagame.com/callback/ (WinINet); the body "<version hash>;<daily run seed>;<daily practice seed>;1;" (the game reads it as lines)
struct Daily {
std::atomic<int> state{0}; // 0 not asked, 1 fetching, 2 ok, 3 failed
uint32_t seed = 0, practice = 0;
std::string version;
std::thread th;
~Daily() { if (th.joinable()) th.detach(); }
void fetch() {
if (state.load() != 0) return;
state = 1;
th = std::thread([this] {
std::string body;
HINTERNET net = InternetOpenA("Noita", INTERNET_OPEN_TYPE_PRECONFIG, nullptr, nullptr, 0);
if (net) {
HINTERNET con = InternetConnectA(net, "takapuoli.noitagame.com", 443, "", "", INTERNET_SERVICE_HTTP, 0, 0);
if (con) {
HINTERNET req = HttpOpenRequestA(con, "GET", "/callback/", "HTTP/1.0", nullptr, nullptr, INTERNET_FLAG_SECURE | INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0);
if (req && HttpSendRequestA(req, nullptr, 0, nullptr, 0)) {
char buf[1024]; DWORD n = 0;
while (InternetReadFile(req, buf, sizeof buf, &n) && n > 0 && body.size() < 4096) body.append(buf, n);
}
if (req) InternetCloseHandle(req);
InternetCloseHandle(con);
}
InternetCloseHandle(net);
}
std::vector<std::string> parts; std::string cur;
for (char ch : body) { if (ch == ';' || ch == '\n') { parts.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; }
if (!cur.empty()) parts.push_back(cur);
auto num = [](const std::string& s, uint32_t& out) { if (s.empty() || s.size() > 10 || s.find_first_not_of("0123456789") != std::string::npos) return false; out = (uint32_t)std::strtoul(s.c_str(), nullptr, 10); return true; };
if (parts.size() >= 3 && num(parts[1], seed) && num(parts[2], practice)) { version = parts[0]; state = 2; }
else state = 3;
});
}
};
// ---------------------------------------------------------------- the menu ----------------------------------------------------------------
class Menu {
public:
enum class Screen { None, Title, NewGame, Pause, Options, ReleaseNotes, Credits, Mods, Progress, GameOver, GameOverStats, Confirm };
Screen screen = Screen::None;
Daily daily;
bool active() const { return screen != Screen::None; }
void open(Screen s) { screen = s; hover_ = -1; scroll_ = 0; if (s == Screen::NewGame) { selMode_ = -1; } if (s == Screen::Credits) creditsT_ = 0.0; }
void game_over(const std::string& cause) { cause_ = cause; gameOverT_ = 0.0; screen = Screen::GameOver; }
void confirm(const std::string& text, Action yes) { confirmText_ = text; confirmAction_ = yes; confirmBack_ = screen; screen = Screen::Confirm; }
bool in_game() const { return inGame_; }
bool over_game() const { return screen == Screen::GameOver || screen == Screen::GameOverStats; } // (the game over plays over the running world: no pause, no background)
void set_in_game(bool b) { inGame_ = b; }
uint32_t last_daily_played = 0; // (stats/_last_daily_run_played.txt: the daily can be played once a day)
Result frame(hud::Canvas& cv, const Assets& as, const Context& c) {
Result r;
if (!active() || c.suspended) return r;
gs_ = std::max(1, (int)std::lround(c.h / 360.0));
cv_ = &cv; as_ = &as; c_ = &c; tick_ += 1.0 / 60.0;
clicked_ = c.click; hot_ = -1; itemN_ = 0;
if (screen == Screen::Title || screen == Screen::NewGame || (!inGame_ && !c.dead && (screen == Screen::Options || screen == Screen::ReleaseNotes || screen == Screen::Credits || screen == Screen::Mods || screen == Screen::Progress || screen == Screen::Confirm)))
background();
else if (!over_game()) { cv.rect(0, 0, cv.w, cv.h, hud::rgba(0, 0, 0, 150)); cv.mark_all(); }
switch (screen) {
case Screen::Title: title(r); break;
case Screen::Pause: pause(r); break;
case Screen::NewGame: newGame(r); break;
case Screen::Options: options(r); break;
case Screen::ReleaseNotes: releaseNotes(); break;
case Screen::Credits: credits(); break;
case Screen::Mods: mods(r); break;
case Screen::Progress: progress(r); break;
case Screen::GameOver: gameOver(r); break;
case Screen::GameOverStats: gameOverStats(r); break;
case Screen::Confirm: confirmScreen(r); break;
default: break;
}
if (c.escape) { // Esc: back to the menu behind, or (from the pause menu) back to the game
if (screen == Screen::Pause) { screen = Screen::None; r.action = Action::Continue; }
else if (screen == Screen::Title || screen == Screen::GameOver || screen == Screen::GameOverStats) {}
else if (screen == Screen::NewGame && c.dead) screen = Screen::GameOverStats;
else if (screen == Screen::Confirm) screen = confirmBack_;
else { if (screen == Screen::Progress) r.action = Action::ProgressSeen; screen = back(); }
}
if (as.cursor.ok()) cv.icon(as.cursor, c.mx, c.my, std::max(1, gs_ / 2)); // (the game's cursor: the system's is hidden over the window)
return r;
}
private:
hud::Canvas* cv_ = nullptr; const Assets* as_ = nullptr; const Context* c_ = nullptr;
int gs_ = 2, hover_ = -1, hot_ = -1, itemN_ = 0, selMode_ = 0, tab_ = 0, scroll_ = 0, bindIdx_ = -1, sliderDrag_ = -1;
bool clicked_ = false, inGame_ = false;
double tick_ = 0.0, creditsT_ = 0.0, gameOverT_ = 0.0;
std::string cause_, confirmText_; Action confirmAction_ = Action::None; Screen confirmBack_ = Screen::Title;
std::vector<uint32_t> bg_; int bgW_ = 0, bgH_ = 0;
uint32_t white() const { return hud::rgba(255, 255, 255); }
uint32_t grey() const { return hud::rgba(190, 190, 200); }
uint32_t dim() const { return hud::rgba(120, 120, 130); }
uint32_t yellow() const { return hud::rgba(255, 230, 80); }
std::string tr(const std::string& k) const { return c_->tr ? c_->tr(k) : k; }
Screen back() const { return c_->dead ? Screen::GameOverStats : inGame_ ? Screen::Pause : Screen::Title; } // the screen a sub-menu returns to
std::string fmt(const std::string& key, const std::string& a0, const std::string& a1 = std::string()) const { // the game's "$0" / "$1" substitution
std::string s = tr(key);
for (const auto& [tag, val] : {std::pair<const char*, const std::string*>{"$0", &a0}, {"$1", &a1}}) { const size_t p = s.find(tag); if (p != std::string::npos) s.replace(p, 2, *val); }
return s;
}
int tw(const std::string& s) const { return cv_->text_width(s, gs_); }
int lineH() const { return 11 * gs_; }
// ---- the parallax background: the sky's gradient, the mountain layers, drifting clouds and twinkling stars (the title and the game over screens) ----
void background() {
hud::Canvas& cv = *cv_; const Assets& as = *as_;
if (bgW_ != cv.w || bgH_ != cv.h) {
bgW_ = cv.w; bgH_ = cv.h; bg_.assign((size_t)cv.w * cv.h, 0xFF101018u);
hud::Canvas tmp; tmp.px = bg_.data(); tmp.w = cv.w; tmp.h = cv.h; tmp.tiles.assign((size_t)((cv.w + 31) >> 5) * ((cv.h + 31) >> 5), 0); tmp.tw = (cv.w + 31) >> 5; tmp.th = (cv.h + 31) >> 5;
// parallax_colors_main_menu.bmp: a column is a time of day (512 of them), the rows come in bands of 20 (a colour, then a white and a black marker row):
// band 0 the sky's top, 1 its horizon, 2 the clouds, 3 .. the mountain layers from the far one. Column 288 is the night of the title screen.
auto band = [&](int k) -> uint32_t { if (as.sky.empty()) return k == 0 ? 0xFF31253Au : k == 1 ? 0xFF5A4D54u : k == 3 ? 0xFF383C52u : 0xFF292E43u; return as.sky[(size_t)std::min(as.skyH - 1, 20 * k) * as.skyW + std::min(as.skyW - 1, 288)]; };
const uint32_t top = band(0), bot = band(1);
for (int y = 0; y < cv.h; ++y) {
const double f = std::min(1.0, y / (cv.h * 0.75));
auto mix = [&](int sh) { return (uint32_t)(((top >> sh) & 255) * (1.0 - f) + ((bot >> sh) & 255) * f); };
const uint32_t c = 0xFF000000u | mix(16) << 16 | mix(8) << 8 | mix(0);
for (int x = 0; x < cv.w; ++x) bg_[(size_t)y * cv.w + x] = c;
}
auto layer = [&](const hud::Icon& ic, double scale, int dy, uint32_t tint) { // the layers are white masks: tinted with their band's colour
if (!ic.ok()) return;
hud::Icon t2 = ic; for (uint32_t& p : t2.px) p = (p & 0xFF000000u) | (tint & 0xFFFFFFu);
const int w = (int)(ic.w * scale), h = (int)(ic.h * scale); tmp.icon_fit(t2, (cv.w - w) / 2, cv.h - h + dy, w, h);
};
const double sc = std::max(cv.w / 1024.0, cv.h * 0.75 / 512.0);
layer(as.layer2, sc, (int)(cv.h * 0.08), band(3)); layer(as.layer1, sc, (int)(cv.h * 0.16), band(4));
}
std::memcpy(cv.px, bg_.data(), bg_.size() * 4); cv.mark_all();
uint32_t seed = 12345u; // the stars (a fixed scatter over the sky, twinkling)
for (int i = 0; i < 60; ++i) {
seed = seed * 1664525u + 1013904223u; const int x = (int)((seed >> 8) % (uint32_t)cv.w);
seed = seed * 1664525u + 1013904223u; const int y = (int)((seed >> 8) % (uint32_t)std::max(1, cv.h * 6 / 10));
seed = seed * 1664525u + 1013904223u; const hud::Icon& st = as.stars[(seed >> 8) % 7];
const double tw = 0.6 + 0.4 * std::sin(tick_ * (1.0 + (i % 5) * 0.3) + i);
if (!st.ok()) continue;
const int s = std::max(1, gs_ / 2);
for (int j = 0; j < st.h; ++j) for (int k = 0; k < st.w; ++k) { const uint32_t p = st.px[(size_t)j * st.w + k]; if (p >> 24) cv.rect(x + k * s, y + j * s, s, s, (p & 0xFFFFFFu) | (uint32_t)((p >> 24) * tw) << 24); }
}
}
// ---- widgets ----
bool inRect(int x, int y, int w, int h) const { return c_->mx >= x && c_->mx < x + w && c_->my >= y && c_->my < y + h; }
// a text button centred on cx (or left-aligned at x with centred == false): grey, white when hovered; true when clicked
bool button(int x, int y, const std::string& text, bool centred = true, bool enabled = true) {
const int w = tw(text), h = 8 * gs_, bx = centred ? x - w / 2 : x;
const bool over = enabled && inRect(bx - 2 * gs_, y - 2 * gs_, w + 4 * gs_, h + 4 * gs_);
cv_->text(bx, y, text, !enabled ? dim() : over ? white() : grey(), gs_);
if (over && c_->settings && !c_->settings->cursor) {}
return over && clicked_;
}
void label(int x, int y, const std::string& text, uint32_t col) { cv_->text(x, y, text, col, gs_); }
void panel(int x, int y, int w, int h, bool gray = false) { // a 9-piece box (decorations/9piece0.png: 3-pixel corners)
const hud::Icon& p = gray ? as_->p9gray : as_->p9;
if (!p.ok()) { cv_->rect(x, y, w, h, hud::rgba(20, 20, 24, 230)); cv_->frame(x, y, w, h, hud::rgba(170, 140, 90)); return; }
const int cs = 3, s = gs_;
auto piece = [&](int sx, int sy, int sw, int sh, int dx, int dy, int dw, int dh) {
if (dw <= 0 || dh <= 0) return;
hud::Icon sub; sub.w = sw; sub.h = sh; sub.px.resize((size_t)sw * sh);
for (int j = 0; j < sh; ++j) for (int i = 0; i < sw; ++i) sub.px[(size_t)j * sw + i] = p.px[(size_t)(sy + j) * p.w + sx + i];
cv_->icon_fit(sub, dx, dy, dw, dh);
};
const int c = cs * s;
piece(0, 0, cs, cs, x, y, c, c); piece(p.w - cs, 0, cs, cs, x + w - c, y, c, c); piece(0, p.h - cs, cs, cs, x, y + h - c, c, c); piece(p.w - cs, p.h - cs, cs, cs, x + w - c, y + h - c, c, c);
piece(cs, 0, p.w - 2 * cs, cs, x + c, y, w - 2 * c, c); piece(cs, p.h - cs, p.w - 2 * cs, cs, x + c, y + h - c, w - 2 * c, c);
piece(0, cs, cs, p.h - 2 * cs, x, y + c, c, h - 2 * c); piece(p.w - cs, cs, cs, p.h - 2 * cs, x + w - c, y + c, c, h - 2 * c);
piece(cs, cs, p.w - 2 * cs, p.h - 2 * cs, x + c, y + c, w - 2 * c, h - 2 * c);
}
// the text of a toggle "Label: Value" that cycles on a click; returns true when clicked
bool toggle(int x, int y, const std::string& text, const std::string& value) { return button(x, y, text + " " + value, false); }
// a slider: the label, then a bar of `bw` px and the value as "$menu_slider_percentage" (006c6470: x 100, "%"); drag sets `v` in lo..hi
void slider(int x, int y, const std::string& text, float& v, int id, float lo = 0.0f, float hi = 1.0f) {
label(x, y, text, grey());
const int bx = x + tw(text) + 6 * gs_, bw = 60 * gs_, bh = 6 * gs_, by = y + gs_;
const float f = std::clamp((v - lo) / (hi - lo), 0.0f, 1.0f);
cv_->frame(bx, by, bw, bh, grey()); cv_->rect(bx + 1, by + 1, (int)((bw - 2) * f), bh - 2, hud::rgba(200, 180, 120));
char pct[16]; std::snprintf(pct, sizeof pct, "%d", (int)std::lround(v * 100.0f));
label(bx + bw + 4 * gs_, y, fmt("$menu_slider_percentage", pct), grey());
const bool over = inRect(bx - 2 * gs_, by - 2 * gs_, bw + 4 * gs_, bh + 4 * gs_);
if (over && c_->lmb && (sliderDrag_ < 0 || sliderDrag_ == id)) sliderDrag_ = id;
if (sliderDrag_ == id) { if (c_->lmb) v = lo + (hi - lo) * std::clamp((float)(c_->mx - bx) / (float)(bw - 2), 0.0f, 1.0f); else sliderDrag_ = -1; }
}
void buildLine() { // bottom-left: the build, like the game's "Noita - Build <date>"
const int s = std::max(1, gs_ / 2);
cv_->text(4 * s, cv_->h - 20 * s, "Noita GPU - Build " + c_->build, dim(), s);
if (c_->has_run) cv_->text(4 * s, cv_->h - 11 * s, "Seed: " + std::to_string(c_->seed) + " Mode: " + c_->run_mode + (c_->mods_used ? " Mods used: yes" : ""), dim(), s);
}
void logo(int cy) { // the logo at 0.8 x the GUI scale
const Assets& as = *as_;
if (!as.logo.ok()) { cv_->text(cv_->w / 2 - tw("Noita") , cy, "Noita", yellow(), gs_ * 2); return; }
const double sc = gs_ * 0.8; const int w = (int)(as.logo.w * sc), h = (int)(as.logo.h * sc); // (the wak's logo is 133 x 77: 425 px wide at 1440p like the game's)
cv_->icon_fit(as.logo, (cv_->w - w) / 2, cy - h / 2, w, h);
}
// ---- screens ----
void title(Result& r) {
logo((int)(cv_->h * 0.3));
int y = (int)(cv_->h * 0.5); const int cx = cv_->w / 2;
if (c_->has_run && button(cx, y, tr("$menu_continue"))) { screen = Screen::None; r.action = Action::Continue; }
if (c_->has_run) y += lineH();
if (button(cx, y, tr("$menu_newgame"))) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } y += lineH();
if (button(cx, y, tr("$menu_options"))) open(Screen::Options); y += lineH();
if (button(cx, y, tr("$menu_mods"))) open(Screen::Mods); y += lineH();
if (button(cx, y, tr("$menu_releasenotes"))) open(Screen::ReleaseNotes); y += lineH();
if (button(cx, y, tr("$menu_credits"))) open(Screen::Credits); y += lineH();
if (button(cx, y, tr("$menu_quit"))) r.action = Action::Quit;
buildLine();
}
void pause(Result& r) {
const Assets& as = *as_;
int y = 6 * gs_;
if (as.help.ok()) { const double sc = gs_ * 0.9; const int w = (int)(as.help.w * sc), h = (int)(as.help.h * sc); cv_->icon_fit(as.help, (cv_->w - w) / 2, y, w, h); y += h + 4 * gs_; }
const int cx = cv_->w / 2;
cv_->text(cx - tw(tr("$menu_paused")) / 2, y, tr("$menu_paused"), dim(), gs_); y += lineH();
if (button(cx, y, tr("$menu_continue"))) { screen = Screen::None; r.action = Action::Continue; } y += lineH();
if (button(cx, y, tr("$menu_newgame"))) confirm(tr("$menu_newgame_areyousure"), Action::None); y += lineH();
if (button(cx, y, tr("$menu_progress"))) open(Screen::Progress); y += lineH();
if (button(cx, y, tr("$menu_options"))) open(Screen::Options); y += lineH();
if (button(cx, y, tr("$menu_mods"))) open(Screen::Mods); y += lineH();
if (button(cx, y, tr("$menu_releasenotes"))) open(Screen::ReleaseNotes); y += lineH();
if (button(cx, y, tr("$menu_credits"))) open(Screen::Credits); y += lineH();
if (button(cx, y, tr("$menu_saveandquit"))) r.action = Action::SaveQuit;
buildLine();
}
void confirmScreen(Result& r) { // "Are you sure ...?" Yes / No
const int w = 180 * gs_, h = 50 * gs_, x = (cv_->w - w) / 2, y = (cv_->h - h) / 2;
panel(x, y, w, h);
std::vector<std::string> lines; wrap(confirmText_, w - 12 * gs_, lines);
int ty = y + 6 * gs_;
for (const std::string& l : lines) { label(x + 6 * gs_, ty, l, grey()); ty += 9 * gs_; }
if (button(x + w / 3, y + h - 12 * gs_, tr("$menu_yes"))) {
if (confirmAction_ == Action::None) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } // (a new game from the pause menu: the game mode panel)
else { r.action = confirmAction_; screen = confirmBack_; }
}
if (button(x + 2 * w / 3, y + h - 12 * gs_, tr("$menu_no"))) screen = confirmBack_;
}
void wrap(const std::string& text, int width, std::vector<std::string>& out) const {
std::string line, word;
auto flush = [&]() { out.push_back(line); line.clear(); };
for (size_t i = 0; i <= text.size(); ++i) {
const char ch = i < text.size() ? text[i] : ' ';
if (ch == '\\' && i + 1 < text.size() && text[i + 1] == 'n') { line += (line.empty() || word.empty() ? "" : " ") + word; word.clear(); flush(); ++i; continue; }
if (ch == '\n') { line += (line.empty() || word.empty() ? "" : " ") + word; word.clear(); flush(); continue; }
if (ch == ' ') {
const std::string cand = line.empty() ? word : line + " " + word;
if (!line.empty() && tw(cand) > width) { flush(); line = word; } else line = cand;
word.clear();
} else word += ch;
}
if (!line.empty()) out.push_back(line);
}
void newGame(Result& r) {
const Assets& as = *as_;
const std::vector<GameMode>& modes = *c_->modes;
// (006ceb00: the banners in a framed column, the description box beside it taking the rest of a 274-unit panel, the bar below; nothing selected at first)
const int bw = 64 * gs_, bh = 36 * gs_, gap = 2 * gs_, pw = 208 * gs_;
const int colX = (cv_->w - (bw + pw + 4 * gs_)) / 2, top = (int)(cv_->h * 0.3);
const int ph = std::max(4, (int)modes.size()) * (bh + gap) + gap;
label(colX, top - 12 * gs_, tr("$menu_newgame_selectgamemode"), grey());
panel(colX - gap, top - gap, bw + 2 * gap, ph, true);
for (size_t i = 0; i < modes.size(); ++i) {
const int y = top + (int)i * (bh + gap);
const hud::Icon* bg = &as.bannerNormalBg; const hud::Icon* fg = &as.bannerNormalFg;
if (modes[i].id == "dailyrun") { bg = &as.bannerDailyBg; fg = &as.bannerDailyFg; }
if (modes[i].mod) { bg = modeIcon(modes[i].banner_bg, bg); fg = modeIcon(modes[i].banner_fg, fg); }
if (bg && bg->ok()) cv_->icon_fit(*bg, colX, y, bw, bh); else cv_->rect(colX, y, bw, bh, hud::rgba(60, 50, 70));
if (fg && fg->ok()) cv_->icon_fit(*fg, colX, y, bw, bh);
const bool over = inRect(colX, y, bw, bh);
if (over) selMode_ = (int)i; // the banner under the mouse is the one described (it stays until another is hovered)
if ((int)i == selMode_ && as.bannerSel.ok()) cv_->icon_fit(as.bannerSel, colX, y, bw, bh);
if (over && clicked_) { selMode_ = (int)i; startMode(r); }
}
// the description box
const int px = colX + bw + 4 * gs_;
panel(px, top - gap, pw, ph, false);
if (selMode_ >= 0 && selMode_ < (int)modes.size()) {
const GameMode& m = modes[selMode_];
int ty = top + 4 * gs_;
label(px + 6 * gs_, ty, tr(m.name), yellow()); ty += lineH();
std::vector<std::string> lines; wrap(tr(m.desc), pw - 12 * gs_, lines);
for (const std::string& l : lines) { label(px + 6 * gs_, ty, l, grey()); ty += 9 * gs_; }
if (m.id == "dailyrun" || m.id == "daily_practice") {
ty += 4 * gs_;
std::string st;
if (!c_->settings || !c_->settings->online) st = tr("$gamemode_dailyrun_desc_offline");
else if (daily.state.load() == 1) st = "Connecting...";
else if (daily.state.load() == 3) st = tr("$gamemode_dailyrun_desc_connection_failed");
else if (daily.state.load() == 2) {
if (m.id == "dailyrun" && last_daily_played == daily.seed) st = tr("$gamemode_dailyrun_desc_played");
else st = (m.id == "dailyrun" ? "Daily run seed: " : "Daily practice seed: ") + std::to_string(m.id == "dailyrun" ? daily.seed : daily.practice);
}
lines.clear(); wrap(st, pw - 12 * gs_, lines);
for (const std::string& l : lines) { label(px + 6 * gs_, ty, l, grey()); ty += 9 * gs_; }
}
}
// the bar below: the streamer mode, the mod settings
const int by = top + ph + 2 * gs_;
panel(colX - gap, by, bw + pw + 4 * gs_ + 2 * gap, 14 * gs_, true);
{ // 006ceb00: "Streamer mode: on/off" is the Twitch integration's switch; switching it on opens the Options at the Streaming tab (to connect)
Settings* S = c_->settings; const bool sm = S && S->streamer_mode;
if (toggle(colX + 4 * gs_, by + 3 * gs_, tr("$menu_streamermode").substr(0, tr("$menu_streamermode").find("$0")), tr(sm ? "$option_on" : "$option_off")) && S) {
S->streamer_mode = !S->streamer_mode; S->save();
if (S->streamer_mode) { open(Screen::Options); tab_ = 4; }
}
}
if (button(colX + bw + pw - tw(tr("$menu_mods_settings_short")), by + 3 * gs_, tr("$menu_mods_settings_short"), false)) { open(Screen::Options); tab_ = 5; }
if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = c_->dead ? Screen::GameOverStats : (inGame_ ? Screen::Pause : Screen::Title);
}
std::map<std::string, hud::Icon> modeIcons_;
const hud::Icon* modeIcon(const std::string& file, const hud::Icon* fallback) {
if (file.empty()) return fallback;
auto it = modeIcons_.find(file);
if (it == modeIcons_.end()) { hud::Icon ic; if (loose_icon) ic = loose_icon(file); it = modeIcons_.emplace(file, std::move(ic)).first; }
return it->second.ok() ? &it->second : fallback;
}
public:
std::function<hud::Icon(const std::string&)> loose_icon; // a mod's banner (mods/<id>/files/...), read through the wak's loose root
private:
bool canStart(const GameMode& m) const {
if (m.id == "dailyrun") return daily.state.load() == 2 && last_daily_played != daily.seed;
if (m.id == "daily_practice") return daily.state.load() == 2;
return true;
}
void startMode(Result& r) {
const GameMode& m = (*c_->modes)[selMode_];
if (!canStart(m)) return;
r.action = Action::StartGame; r.mode_id = m.id;
if (m.id == "dailyrun") { r.seed = daily.seed; r.daily = true; }
else if (m.id == "daily_practice") { r.seed = daily.practice; r.daily_practice = true; }
else { uint32_t s = (uint32_t)GetTickCount() ^ (uint32_t)std::time(nullptr) * 2654435761u; r.seed = s ? s : 1u; }
screen = Screen::None;
}
void options(Result& r) {
const Assets& as = *as_;
static const char* tabs[6] = {"General", "Graphics", "Audio", "Input", "Streaming", "Mod settings"};
const int pw = std::min(cv_->w - 16 * gs_, 280 * gs_), px = (cv_->w - pw) / 2, py = 24 * gs_, ph = cv_->h - py - 24 * gs_;
int tx = px + 4 * gs_;
for (int i = 0; i < 6; ++i) {
const int tw0 = 44 * gs_, th = 10 * gs_;
const bool sel = tab_ == i, over = inRect(tx, py - th, tw0, th);
const hud::Icon& ic = sel ? as.tabSel : (over ? as.tabHover : as.tab);
if (ic.ok()) cv_->icon_fit(ic, tx, py - th - (sel ? gs_ : 0), tw0, th + (sel ? gs_ : 0)); else cv_->frame(tx, py - th, tw0, th, grey());
const std::string t = tabs[i]; const int s = std::max(1, gs_ * 2 / 3);
cv_->text(tx + (tw0 - cv_->text_width(t, s)) / 2, py - th + 3 * gs_ - (sel ? gs_ : 0), t, sel ? white() : grey(), s);
if (over && clicked_) { tab_ = i; scroll_ = 0; bindIdx_ = -1; }
tx += tw0 + gs_;
}
panel(px, py, pw, ph);
Settings& S = *c_->settings;
int x = px + 10 * gs_, y = py + 8 * gs_ - scroll_;
auto header = [&](const char* t) { label(x, y, t, dim()); y += lineH(); };
auto onoff = [&](bool b) { return tr(b ? "$option_on" : "$option_off"); };
auto opt = [&](const char* key, bool& b) { if (toggle(x, y, tr(key).substr(0, tr(key).find("$0")), onoff(b))) b = !b; y += lineH(); };
if (tab_ == 0) {
header("WINDOW");
if (toggle(x, y, "Window mode:", S.window_mode == 0 ? "Windowed" : "Fullscreen")) S.window_mode = 1 - S.window_mode; y += lineH();
label(x, y, "Resolution: " + std::to_string(c_->w) + " x " + std::to_string(c_->h), grey()); y += lineH();
opt("$menuoptions_vsync", S.vsync);
y += 4 * gs_; header("COMPATIBILITY");
opt("$menuoptions_application_rendered_cursor", S.cursor);
y += 4 * gs_; header("REPLAY RECORDER");
opt("$menuoptions_replayrecorder", S.replay_recorder);
y += 4 * gs_; header("ONLINE");
if (toggle(x, y, "Online features:", onoff(S.online))) S.online = !S.online; y += lineH();
y += 4 * gs_; header("MISCELLANEOUS");
label(x, y, tr("$menuoptions_language"), grey()); y += lineH();
if (button(x, y, tr("$menuoptions_resetsave"), false)) confirm(tr("$menuoptions_resetsave_areyousure"), Action::ResetProgress);
y += lineH();
} else if (tab_ == 1) {
header(tr("$menuoptions_heading_rendering").c_str());
slider(x, y, tr("$menuoptions_brightness"), S.brightness, 1, Settings::kBrightMin, Settings::kBrightMax); y += lineH();
slider(x, y, tr("$menuoptions_contrast"), S.contrast, 2, Settings::kContrastMin, Settings::kContrastMax); y += lineH();
slider(x, y, tr("$menuoptions_gamma"), S.gamma, 3, Settings::kGammaMin, Settings::kGammaMax); y += lineH();
slider(x, y, tr("$menuoptions_cosmeticparticlecoeff"), S.cosmetic_particles, 4, Settings::kCosmeticMin, 1.0f); y += lineH();
r.action = Action::ApplySettings; // (the picture follows the sliders as they move)
y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_graphics").c_str());
opt("$menuoptions_damagenumbers", S.damage_numbers);
y += 4 * gs_; header(tr("$menuoptions_heading_accessibility").c_str());
slider(x, y, tr("$menuoptions_screenshake_intensity"), S.screenshake, 5); y += lineH();
} else if (tab_ == 2) {
header(tr("$menuoptions_heading_volume").c_str());
slider(x, y, tr("$menuoptions_musicvolume"), S.music, 6); y += lineH();
slider(x, y, tr("$menuoptions_soundsvolume"), S.sfx, 7); y += lineH();
} else if (tab_ == 3) {
header(tr("$menuoptions_heading_controls").c_str());
for (size_t i = 0; i < S.keys.size(); ++i) {
KeyBind& k = S.keys[i];
const std::string v = bindIdx_ == (int)i ? "..." : keyName(k.key);
if (toggle(x, y, std::string(k.name) + ":", v)) bindIdx_ = (int)i;
y += 9 * gs_;
}
if (bindIdx_ >= 0 && c_->key_pressed) { if (c_->key_pressed != VK_ESCAPE) S.keys[bindIdx_].key = c_->key_pressed; bindIdx_ = -1; }
label(x, y, tr("$menuoptions_reset_keyboard"), dim()); y += lineH();
if (button(x, y, tr("$menuoptions_configurecontrols_reset_all"), false)) { Settings d; S.keys = d.keys; } y += lineH();
y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_mouse").c_str());
opt("$menuoptions_ui_inventory_icons_always_clickable", S.click_switches_item);
opt("$menuoptions_showhoverinfonexttomouse", S.hover_info_at_mouse);
y += 4 * gs_; header(tr("$menuoptions_heading_userinterface_input").c_str());
opt("$menuoptions_pause_the_game_when_unfocused", S.pause_unfocused);
} else if (tab_ == 4) { // 006d5620: TWITCH CHANNEL - Connect / Disconnect (the OAuth sign-in and the chat votes), then the vote settings; the switch itself is the streamer mode
header(tr("$menuoptions_heading_streaming_integration").c_str());
if (toggle(x, y, tr("$menu_streamermode").substr(0, tr("$menu_streamermode").find("$0")), onoff(S.streamer_mode))) S.streamer_mode = !S.streamer_mode; y += lineH();
button(x, y, tr("$menu_streaming_connect_twitch"), false, false); y += lineH();
std::vector<std::string> lines; wrap("The Twitch sign-in and the chat votes are not part of this build (to-do #47).", pw - 20 * gs_, lines);
for (const std::string& l : lines) { label(x, y, l, dim()); y += 9 * gs_; }
} else {
header("MODS");
if (!c_->mods || c_->mods->empty()) label(x, y, "No mods enabled.", grey());
for (const std::string& m : c_->mods ? *c_->mods : std::vector<std::string>()) { label(x, y, m + " - " + tr("$menu_mods_nosettings"), grey()); y += 9 * gs_; }
}
scrollBy(c_->wheel * 12 * gs_, std::max(0, y + scroll_ - (py + ph) + 20 * gs_));
if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_applyandreturn"), false)) { S.save(); r.action = Action::ApplySettings; screen = back(); }
}
void scrollBy(int d, int maxScroll) { scroll_ = std::clamp(scroll_ + d, 0, std::max(0, maxScroll)); }
static std::string keyName(int k) {
if (k >= 'A' && k <= 'Z') return std::string(1, (char)k);
if (k >= '0' && k <= '9') return std::string(1, (char)k);
switch (k) { case VK_TAB: return "Tab"; case VK_SPACE: return "Space"; case VK_LEFT: return "Left"; case VK_RIGHT: return "Right"; case VK_UP: return "Up"; case VK_DOWN: return "Down";
case VK_SHIFT: return "Shift"; case VK_CONTROL: return "Ctrl"; case VK_RETURN: return "Enter"; case VK_LBUTTON: return "LMB"; case VK_RBUTTON: return "RMB"; default: break; }
char b[16]; std::snprintf(b, sizeof b, "key %d", k); return b;
}
void textScreen(const std::string& titleText, const std::vector<std::string>& lines, bool autoScroll) {
const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_;
label(px, py - 10 * gs_, titleText, grey());
panel(px, py, pw, ph);
const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2;
const int total = (int)lines.size() * lh;
if (autoScroll) { creditsT_ += 1.0; scroll_ = std::clamp((int)(creditsT_ * 0.5 * s) - ph / 2, 0, std::max(0, total - ph + 20 * gs_)); scrollBy(c_->wheel * 3 * lh, total - ph + 20 * gs_); }
else scrollBy(c_->wheel * 3 * lh, total - ph + 20 * gs_);
int y = py + 6 * gs_ - scroll_;
for (const std::string& l : lines) {
uint32_t col = grey(); std::string t = l;
if (!t.empty() && t[0] == '#') { col = yellow(); t = t.substr(1); }
else if (!t.empty() && t[0] == '*') { col = white(); t = t.substr(1); }
std::vector<std::string> parts; // (a long line wraps at the box's width)
std::string line, word;
for (size_t i = 0; i <= t.size(); ++i) {
const char ch = i < t.size() ? t[i] : ' ';
if (ch == ' ') { const std::string cand = line.empty() ? word : line + " " + word; if (!line.empty() && cv_->text_width(cand, s) > pw - 12 * gs_) { parts.push_back(line); line = word; } else line = cand; word.clear(); }
else word += ch;
}
parts.push_back(line);
for (size_t k = 0; k < parts.size(); ++k) {
if (y + lh >= py + 3 * gs_ && y < py + ph - 3 * gs_) cv_->text(px + 6 * gs_ + (k ? 8 * s : 0), y, parts[k], k ? grey() : col, s);
y += lh;
}
}
if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = back();
}
void releaseNotes() {
std::vector<std::string> lines;
if (!c_->release_notes || c_->release_notes->empty()) lines.push_back("No release notes beside the executable (release_notes.txt).");
else { std::string cur; for (char ch : *c_->release_notes) { if (ch == '\n') { lines.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; } if (!cur.empty()) lines.push_back(cur); }
textScreen(tr("$menu_releasenotes"), lines, false);
}
void credits() {
std::vector<std::string> lines;
std::string cur; for (char ch : as_->credits) { if (ch == '\n') { lines.push_back(cur); cur.clear(); } else if (ch != '\r') cur += ch; }
if (!cur.empty()) lines.push_back(cur);
std::vector<std::string> ours = {"*NOITA GPU", "A Vulkan rebuild of Noita's engine.", "", "*ORIGINAL GAME", "Noita by Nolla Games - the art, the data and the design below are theirs.", ""};
ours.insert(ours.end(), lines.begin(), lines.end());
textScreen(tr("$menu_credits"), ours, true);
}
// The Mods screen: the mods of mods/ in load order, a click on the name enables / disables (the game's: "Click mod name to enable/disable the mod"), Move up /
// Move down reorder, the list is written at once (save/mod_config.xml, the game's layout) and applies at the next start - "Restart with enabled mods active"
// relaunches the run, "Start a new game with enabled mods active" opens the game modes. A game-mode mod is played from the New game menu, not enabled here.
void mods(Result& r) {
const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_;
label(px, py - 10 * gs_, tr("$menu_mods"), grey());
panel(px, py, pw, ph);
const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2;
int x = px + 6 * gs_, y = py + 6 * gs_ - scroll_;
std::vector<std::string> help; wrap(tr("$menu_mods_help"), pw - 12 * gs_, help);
for (const std::string& l : help) { cv_->text(x, y, l, dim(), s); y += lh; }
y += lh;
cv_->text(x, y, tr("$menu_mods_availablemods"), white(), s); y += lh + 2 * gs_;
std::vector<ModInfo>* mods = c_->all_mods;
if (!mods || mods->empty()) { cv_->text(x, y, tr("$menu_mods_availablemods_none"), grey(), s); y += lh; }
std::string tip; int tipY = 0;
for (size_t i = 0; mods && i < mods->size(); ++i) {
ModInfo& m = (*mods)[i];
const std::string box = m.game_mode ? " - " : m.enabled ? "[x]" : "[ ]";
const std::string line = box + " " + (m.name.empty() ? m.id : m.name);
const int w = cv_->text_width(line, s);
const bool over = inRect(x - gs_, y - gs_, w + 2 * gs_, lh);
cv_->text(x, y, line, m.game_mode ? dim() : over || m.enabled ? white() : grey(), s);
if (over && clicked_ && !m.game_mode) { m.enabled = !m.enabled; r.action = Action::ApplyMods; }
if (over) { tip = m.desc + (m.game_mode ? tr("$menu_mods_customactivation") : ""); tipY = y; }
const int bx = px + pw - 60 * gs_; // Move up / Move down (small buttons on the row)
auto sbtn = [&](int sx, const std::string& t2) { const int w2 = cv_->text_width(t2, s); const bool ov = inRect(sx - gs_, y - gs_, w2 + 2 * gs_, lh); cv_->text(sx, y, t2, ov ? white() : dim(), s); return ov && clicked_; };
if (i > 0 && sbtn(bx, tr("$menu_mods_moveup"))) { std::swap((*mods)[i], (*mods)[i - 1]); r.action = Action::ApplyMods; }
if (i + 1 < mods->size() && sbtn(bx + 26 * gs_, tr("$menu_mods_movedown"))) { std::swap((*mods)[i], (*mods)[i + 1]); r.action = Action::ApplyMods; }
y += lh;
}
y += lh;
cv_->text(x, y, tr("$menu_mods_help_paused"), dim(), s); y += lh + gs_;
if (button(x, y, tr("$menu_mods_openmodfolder"), false)) r.action = Action::OpenModFolder; y += lineH();
if (c_->has_run && button(x, y, tr("$menu_mods_restart"), false)) { r.action = Action::RestartMods; screen = Screen::None; } if (c_->has_run) y += lineH();
if (button(x, y, tr("$menu_mods_newgame"), false)) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } y += lineH();
if (!tip.empty()) { // the mod's description beside the list
std::vector<std::string> lines; wrap(tip, 110 * gs_, lines);
const int bw = 116 * gs_, bh = (int)lines.size() * lh + 6 * gs_, bx = std::min(cv_->w - bw - 2 * gs_, c_->mx + 6 * gs_), by = std::min(cv_->h - bh - 2 * gs_, tipY);
panel(bx, by, bw, bh, true);
int ly = by + 3 * gs_; for (const std::string& l : lines) { cv_->text(bx + 3 * gs_, ly, l, grey(), s); ly += lh; }
}
scrollBy(c_->wheel * 3 * lh, std::max(0, y + scroll_ - (py + ph) + 20 * gs_));
if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) screen = back();
}
// The Progress screen (006dfe40): three grids - the perks (9 columns), the spells (12) and the enemies (9) - each cell a grid_box with the icon when the
// perk / spell was picked up (the perk_picked_<id> / action_<id> persistent flag) or the enemy killed, grid_box_unknown with icon_unknown otherwise; an entry
// new since the screen was last seen pulses with grid_highlight_new. Hovering shows the name and the description, "???" when unknown (the enemies: Kills /
// Killed by). Below: the endings discovered and the secrets found.
void progress(Result& r) {
const Assets& as = *as_;
const int pw = std::min(cv_->w - 16 * gs_, 300 * gs_), px = (cv_->w - pw) / 2, py = 20 * gs_, ph = cv_->h - py - 24 * gs_;
label(px, py - 10 * gs_, tr("$menu_progress"), grey());
panel(px, py, pw, ph);
const int s = std::max(1, gs_ * 2 / 3), lh = 8 * s + 2;
const int cell = (as.gridBox.ok() ? as.gridBox.w : 20) * gs_, pitch = cell + gs_;
int x = px + 6 * gs_, y = py + 6 * gs_ - scroll_;
const ProgressItem* hot = nullptr; bool hotEnemy = false;
auto grid = [&](const char* header, const std::vector<ProgressItem>* items, int cols, bool enemies) {
if (!items) return;
int known = 0; for (const ProgressItem& it : *items) known += it.known;
cv_->text(x, y, tr(header) + " " + std::to_string(known) + " / " + std::to_string(items->size()), white(), s); y += lh + gs_;
cols = std::max(1, std::min(cols, (pw - 12 * gs_) / pitch));
for (size_t i = 0; i < items->size(); ++i) {
const ProgressItem& it = (*items)[i];
const int cx = x + (int)(i % cols) * pitch, cy = y + (int)(i / cols) * pitch;
if (cy + cell < py + 3 * gs_ || cy > py + ph - 3 * gs_) continue; // (scrolled out of the box)
const hud::Icon& box = it.known || !as.gridBoxUnknown.ok() ? as.gridBox : as.gridBoxUnknown;
if (box.ok()) cv_->icon_fit(box, cx, cy, cell, cell); else cv_->frame(cx, cy, cell, cell, it.known ? grey() : dim());
const hud::Icon* ic = it.known ? iconOf(it.icon) : nullptr;
if (!ic || !ic->ok()) ic = &as.iconUnknown;
if (ic->ok()) { const int iw = std::min(cell - 4 * gs_, ic->w * gs_), ih = std::min(cell - 4 * gs_, ic->h * gs_); cv_->icon_fit(*ic, cx + (cell - iw) / 2, cy + (cell - ih) / 2, iw, ih); }
if (it.is_new && it.known && as.gridNew.ok()) { // (006df000: the highlight's alpha follows a sine of the frame)
hud::Icon hi = as.gridNew; const double a = 0.5 + 0.5 * std::sin(tick_ * 5.0 + i * 0.7);
for (uint32_t& p : hi.px) p = (p & 0xFFFFFFu) | (uint32_t)((p >> 24) * a) << 24;
cv_->icon_fit(hi, cx, cy, cell, cell);
}
if (inRect(cx, cy, cell, cell) && cy >= py && cy + cell <= py + ph) { hot = &it; hotEnemy = enemies; }
}
y += ((int)items->size() + cols - 1) / cols * pitch + lh;
};
grid("$progress_perks", c_->perks, 9, false);
grid("$progress_actions", c_->actions, 12, false);
grid("$progress_enemies", c_->enemies, 9, true);
if (c_->ending0 || c_->ending1) { cv_->text(x, y, tr(c_->ending0 && c_->ending1 ? "$progress_ending01" : c_->ending0 ? "$progress_ending0" : "$progress_ending1"), grey(), s); y += lh; }
cv_->text(x, y, fmt("$progress_completed", std::to_string(c_->secrets_found), ""), grey(), s); y += lh;
if (hot) { // the information box of the cell under the mouse
std::vector<std::string> lines;
if (!hot->known) lines.push_back(tr("$progress_unknown"));
else {
lines.push_back(hot->is_new ? fmt("$progress_new", tr(hot->name)) : tr(hot->name));
if (hotEnemy) { lines.push_back(fmt("$progress_kills", std::to_string(hot->kills))); lines.push_back(fmt("$progress_killed_by", std::to_string(hot->killed_by))); }
else wrap(tr(hot->desc), 110 * gs_, lines);
}
const int bw = 116 * gs_, bh = (int)lines.size() * lh + 6 * gs_, bx = std::min(cv_->w - bw - 2 * gs_, c_->mx + 8 * gs_), by = std::min(cv_->h - bh - 2 * gs_, c_->my + 8 * gs_);
panel(bx, by, bw, bh, true);
int ly = by + 3 * gs_; for (size_t k = 0; k < lines.size(); ++k) { cv_->text(bx + 3 * gs_, ly, lines[k], k == 0 ? white() : grey(), s); ly += lh; }
}
scrollBy(c_->wheel * 3 * lh, std::max(0, y + scroll_ - (py + ph) + 20 * gs_));
if (button(8 * gs_, cv_->h - 14 * gs_, tr("$menu_return"), false)) { r.action = Action::ProgressSeen; screen = back(); }
}
const hud::Icon* iconOf(const std::string& file) { return modeIcon(file, nullptr); } // (any image of the wak or a mod, cached)
void gameOver(Result& r) {
gameOverT_ += 1.0 / 60.0;
const Assets& as = *as_;
const double fade = std::min(1.0, gameOverT_ / 1.5);
const int cx = cv_->w / 2; int y = (int)(cv_->h * 0.18);
if (as.gameOver.ok()) { const int w = as.gameOver.w * gs_ / 1, h = as.gameOver.h * gs_; const int ww = std::min(w, cv_->w - 16 * gs_), hh = h * ww / std::max(1, w); cv_->icon_fit(as.gameOver, cx - ww / 2, y - (int)(10 * gs_ * (1.0 - fade)), ww, hh); y += hh + 4 * gs_; }
if (gameOverT_ < 1.0) return;
std::string cause = tr("$stat_cause_of_death"); const size_t p = cause.find("$0"); if (p != std::string::npos) cause.replace(p, 2, cause_);
cv_->text(cx - tw(cause) / 2, y, cause, grey(), gs_); y += lineH();
if (button(cx, y, tr("$menugameover_nextbutton"))) screen = Screen::GameOverStats;
(void)r;
}
void gameOverStats(Result& r) {
const Assets& as = *as_;
const int cx = cv_->w / 2; int y = (int)(cv_->h * 0.18);
if (as.gameOver.ok()) { const int w = as.gameOver.w * gs_, h = as.gameOver.h * gs_; const int ww = std::min(w, cv_->w - 16 * gs_), hh = h * ww / std::max(1, w); cv_->icon_fit(as.gameOver, cx - ww / 2, y, ww, hh); y += hh - 6 * gs_; }
const int pw = 192 * gs_, px = cx - pw / 2, ph = std::min(200 * gs_, cv_->h - y - 4 * gs_); // (006e51e0: the box of 192 x 200 units under the title)
panel(px, y, pw, ph);
int ty = y + 12 * gs_; const int lx = px + 36 * gs_;
auto row = [&](const std::string& key, const std::string& val, const std::string& best) {
label(lx, ty, tr(key), grey());
cv_->text(lx + tw(tr(key)) + 3 * gs_, ty, val, yellow(), gs_);
if (!best.empty()) { std::string b = tr("$stat_best"); const size_t p = b.find("$0"); if (p != std::string::npos) b.replace(p, 2, best); cv_->text(lx + tw(tr(key)) + tw(val) + 6 * gs_, ty, b, dim(), gs_); }
ty += 9 * gs_;
};
std::string cause = tr("$stat_cause_of_death"); const size_t p = cause.find("$0"); if (p != std::string::npos) cause.replace(p, 2, cause_);
label(lx, ty, cause, grey()); ty += 9 * gs_;
row("$stat_modsenabled", tr(c_->mods_used ? "$menu_yes" : "$menu_no"), "");
auto timeStr = [](double s) { char b[32]; const int t = (int)s; std::snprintf(b, sizeof b, "%d:%02d:%02d", t / 3600, (t / 60) % 60, t % 60); return std::string(b); };
row("$stat_gold", std::to_string(c_->stats.gold), std::to_string(c_->best.gold));
row("$stat_time", timeStr(c_->stats.playtime), timeStr(c_->best.playtime));
row("$stat_depth", std::to_string(c_->stats.depth) + "m", std::to_string(c_->best.depth) + "m");
row("$stat_places_visited", std::to_string(c_->stats.places), std::to_string(c_->best.places));
row("$stat_enemies_slain", std::to_string(c_->stats.kills), std::to_string(c_->best.kills));
row("$stat_max_hp", std::to_string(c_->stats.maxhp), std::to_string(c_->best.maxhp));
row("$stat_items_found", std::to_string(c_->stats.items), std::to_string(c_->best.items));
row("$stat_total_deaths", std::to_string(c_->total_deaths), "");
row("$stat_total_wins", std::to_string(c_->total_wins), "");
ty += 8 * gs_;
if (button(cx, ty, tr("$menugameover_newgame"))) { open(Screen::NewGame); if (c_->settings && c_->settings->online) daily.fetch(); } ty += lineH();
if (button(cx, ty, tr("$menugameover_savereplay"), true, c_->replay_available)) r.action = Action::SaveReplay; ty += lineH(); // (006e51e0: with the replay recorder on, the clip editor over the game over)
if (button(cx, ty, tr("$menu_progress"))) open(Screen::Progress); ty += lineH();
if (button(cx, ty, tr("$menugameover_quit"))) r.action = Action::Quit;
}
};
} // namespace menu
+1 -1
View File
@@ -9,7 +9,7 @@ constexpr int kRing = 1024; // commands
constexpr int kHandles = 4096; // kept instances (handle % kHandles)
constexpr int kName = 120;
enum Op : int32_t { None = 0, PlayOneShot, Start, Move, SetParam, Stop, StopNow, Listener, SetVolume, Quit, Release };
enum Op : int32_t { None = 0, PlayOneShot, Start, Move, SetParam, Stop, StopNow, Listener, SetVolume, Quit, Release, Pause }; // Pause: value 1 pauses every kept instance, 0 resumes them (the game's pause menu)
struct Cmd {
int32_t op = None;
+3 -2
View File
@@ -59,13 +59,13 @@ bool FmodAudio::push(int op, int handle, float x, float y, float value, const st
return true;
}
void FmodAudio::play(const std::string& event, float x, float y, float volume) { if (!event.empty()) push(PlayOneShot, 0, x, y, volume, event); }
void FmodAudio::play(const std::string& event, float x, float y, float volume) { if (!event.empty()) push(PlayOneShot, 0, x, y, volume * sfx_gain_, event); }
int FmodAudio::start(const std::string& event, float x, float y, float volume) {
if (event.empty() || !ok()) return 0;
const int h = next_handle_++;
if (next_handle_ >= kHandles * 1000) next_handle_ = 1;
if (!push(Start, h, x, y, volume, event)) return 0;
if (!push(Start, h, x, y, volume * (event.find("music") != std::string::npos ? music_gain_ : sfx_gain_), event)) return 0;
sh_->playing[h % kHandles] = 1;
return h;
}
@@ -73,6 +73,7 @@ int FmodAudio::start(const std::string& event, float x, float y, float volume) {
void FmodAudio::move(int handle, float x, float y) { if (handle > 0) push(Move, handle, x, y, 0, ""); }
void FmodAudio::set_parameter(int handle, const std::string& name, float value) { if (handle > 0) push(SetParam, handle, 0, 0, value, name); }
void FmodAudio::set_volume(int handle, float volume) { if (handle > 0) push(SetVolume, handle, 0, 0, volume, ""); }
void FmodAudio::set_paused(bool paused) { push(Pause, 0, 0, 0, paused ? 1.0f : 0.0f, ""); }
void FmodAudio::release(int handle) { if (handle > 0) push(Release, handle, 0, 0, 0, ""); }
void FmodAudio::stop(int handle, bool immediate) { if (handle > 0) push(immediate ? StopNow : Stop, handle, 0, 0, 0, ""); }
bool FmodAudio::playing(int handle) const { return handle > 0 && ok() && sh_->playing[handle % kHandles] != 0; }
+3
View File
@@ -18,11 +18,14 @@ public:
bool ok() const; // the host is up and has loaded the banks
// a one-shot event at a world position (pixels): "game_effect/teleport/tick" or "event:/game_effect/teleport/tick"
void play(const std::string& event, float x, float y, float volume = 1.0f);
void set_gains(float sfx, float music) { sfx_gain_ = sfx; music_gain_ = music; } // the Options' volumes: one-shots and started events ("music/" events: the music gain)
float sfx_gain_ = 1.0f, music_gain_ = 1.0f;
// a kept instance (loops, sounds that follow an entity): handle > 0
int start(const std::string& event, float x, float y, float volume = 1.0f);
void move(int handle, float x, float y);
void set_parameter(int handle, const std::string& name, float value);
void set_volume(int handle, float volume);
void set_paused(bool paused); // every started instance holds (the pause menu); one-shots still play
void stop(int handle, bool immediate = false);
void release(int handle); // the instance plays to its end on its own; the handle is free
bool playing(int handle) const;
+42
View File
@@ -0,0 +1,42 @@
#pragma once
#include "entity/material_mix.h"
#include "core/materials.h"
namespace ng {
struct PotionSpray {
double speed = 1, minimum = .5;
bool bunch = false, skip_static = false, leak_gas = false;
int count = 5;
};
// 00bfdd10: one attempt per material, or throw_how_many on the initial press.
// Velocities are pixels/second here; the GPU transport converts to pixels/tick.
template<class Random, class Emit>
int potion_spray(std::vector<MixPart>& parts, const MaterialDb& db, const PotionSpray& cfg,
double capacity, double aim_x, double aim_y, double holder_vx,
bool pressed, Random random, Emit emit) {
if (capacity <= 0 || (cfg.bunch && !pressed)) return 0;
int attempts = 0;
for (auto& part : parts) {
if (part.n <= 0 || (cfg.bunch && (int)part.n <= 0)) continue;
const Material* m = db.find(part.mat);
if (!m) continue;
if (!cfg.bunch && cfg.skip_static && (m->type == CellType::Solid ||
(m->type == CellType::Liquid && attr_int(*m, "liquid_static", 0)))) continue;
const double speed = cfg.speed * std::max(cfg.minimum, std::max(1, (int)part.n) / capacity);
const int count = cfg.bunch ? std::max(0, cfg.count) : 1;
for (int i = 0; i < count && part.n > 0; ++i) {
const double angle = (random() * 2 - 1) * (cfg.bunch ? .25 : .05);
const double c = std::cos(angle), s = std::sin(angle), length = std::hypot(aim_x, aim_y);
double vx = length > 0 ? (aim_x * c - aim_y * s) / length * speed : 0;
double vy = length > 0 ? (aim_x * s + aim_y * c) / length * speed : 0;
vx += holder_vx;
if (!cfg.bunch && cfg.leak_gas && m->type == CellType::Gas) { vx = 0; vy = -10; }
emit(*m, vx, vy);
part.n -= 1; // Native spends contents even when the particle pool is full.
++attempts;
}
}
return attempts;
}
} // namespace ng
+4 -1
View File
@@ -90,6 +90,9 @@ int n_GameGetRealWorldTimeSinceStarted(LuaHost&, lua_State* L) {
lua_pushnumber(L, std::chrono::duration<double>(std::chrono::steady_clock::now() - t0).count());
return 1;
}
// the game modes of the menu (src/app/menu.h): NG_DAILY=1 a daily run, 2 a daily practice run (the seed came from the backend)
int n_GameIsDailyRun(LuaHost&, lua_State* L) { const char* d = std::getenv("NG_DAILY"); lua_pushboolean(L, d && std::string(d) == "1"); return 1; }
int n_GameIsDailyRunOrPractice(LuaHost&, lua_State* L) { const char* d = std::getenv("NG_DAILY"); lua_pushboolean(L, d && *d); return 1; }
int n_GetDailyPracticeRunSeed(LuaHost&, lua_State* L) { // the day's seed: the UTC date
const std::time_t t = std::time(nullptr);
std::tm tm{};
@@ -344,7 +347,7 @@ void register_host_natives(LuaHost& h) {
{"SessionNumbersGetValue", n_SessionNumbersGetValue}, {"SessionNumbersSetValue", n_SessionNumbersSetValue}, {"SessionNumbersSave", n_Nop},
{"MagicNumbersGetValue", n_MagicNumbersGetValue}, {"ModMagicNumbersFileAdd", n_ModMagicNumbersFileAdd},
{"GameGetDateAndTimeUTC", n_GameGetDateAndTimeUTC}, {"GameGetRealWorldTimeSinceStarted", n_GameGetRealWorldTimeSinceStarted}, {"GetDailyPracticeRunSeed", n_GetDailyPracticeRunSeed},
{"GameIsDailyRun", n_False}, {"GameIsDailyRunOrDailyPracticeRun", n_False}, {"GameIsBetaBuild", n_False}, {"DebugGetIsDevBuild", n_False}, {"GameIsIntroPlaying", n_False},
{"GameIsDailyRun", n_GameIsDailyRun}, {"GameIsDailyRunOrDailyPracticeRun", n_GameIsDailyRunOrPractice}, {"GameIsBetaBuild", n_False}, {"DebugGetIsDevBuild", n_False}, {"GameIsIntroPlaying", n_False},
{"DebugBiomeMapGetFilename", n_DebugBiomeMapGetFilename},
// release-build behaviour: these do nothing outside the developers' builds (EntitySave, trailer mode, the test player) or report to Steam
{"EntitySave", n_Nop}, {"DebugEnableTrailerMode", n_Nop}, {"Debug_SaveTestPlayer", n_Nop}, {"GameGiveAchievement", n_Nop}, {"AutosaveDisable", n_Nop},
+3 -1
View File
@@ -520,7 +520,9 @@ LuaHost::~LuaHost() {
if (L_) lua_close(L_);
}
const std::string& LuaHost::translate(const std::string& key) {
const std::string& LuaHost::translate(const std::string& key) { return translate_in(wak_, loose_dir, key); }
const std::string& LuaHost::translate_in(const Wak& wak_, const std::string& loose_dir, const std::string& key) {
static const std::string none;
if (!texts_loaded_) {
texts_loaded_ = true;
+4 -2
View File
@@ -171,6 +171,8 @@ public:
bool buffer_world_writes = false, wrote_world = false;
std::set<std::string> removed_run_flags;
std::string loose_dir; // the game folder next to data.wak (the translations are loose files there)
// translate() without a host (the menus before a run): the wak and the folder beside it
static const std::string& translate_in(const Wak& wak, const std::string& loose_dir, const std::string& key);
std::map<std::string, double> stats, biome_stats_base; // StatsGetValue / StatsBiomeGetValue (the host counts enemies_killed...; StatsResetBiome starts the biome counters over)
int next_entity_id = 1000;
struct GenComp { int ent = 0; int index = 0; std::string type; }; // index >= 0: the spawn's recorded component; -1 - k: the k-th component of the file
@@ -194,8 +196,8 @@ private:
std::vector<ActionRow> actions_;
size_t actions_len_ = (size_t)-1;
bool tried_actions_ = false;
std::map<std::string, std::string> texts_;
bool texts_loaded_ = false;
static inline std::map<std::string, std::string> texts_; // (one table for every VM and the menus)
static inline bool texts_loaded_ = false;
};
} // namespace ng
+13
View File
@@ -0,0 +1,13 @@
# The 32-bit audio host (the game's FMOD DLLs are x86). Built on its own:
# cmake -S tools/audio_host -B build_audio32 -A Win32 && cmake --build build_audio32 --config Release
# then audio_host.exe goes next to noita_gpu.exe (and into demo/).
cmake_minimum_required(VERSION 3.20)
project(audio_host CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(audio_host audio_host.cpp)
target_include_directories(audio_host PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/../../src)
if(MSVC)
target_compile_options(audio_host PRIVATE /W4)
target_compile_definitions(audio_host PRIVATE _CRT_SECURE_NO_WARNINGS)
set_property(TARGET audio_host PROPERTY MSVC_RUNTIME_LIBRARY "MultiThreaded$<$<CONFIG:Debug>:Debug>")
endif()
+210
View File
@@ -0,0 +1,210 @@
// audio_host: a 32-bit helper that plays the game's sounds with the FMOD Studio of the Noita install (fmodstudio.dll / fmod.dll 2.1.5 are x86 and the
// game is 64-bit). It loads those DLLs at run time from the Noita folder (they are never copied or shipped), loads the banks of data/audio/Desktop and runs
// the commands the game writes into the shared memory of src/audio/audio_ipc.h. It ends with the game.
// "Made with FMOD Studio by Firelight Technologies Pty Ltd."
// usage: audio_host <noita dir> <shared memory name> <game pid>
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <cstdio>
#include <cstring>
#include <map>
#include <string>
#include <vector>
#include "audio/audio_ipc.h"
using namespace ng::audio_ipc;
namespace {
struct FVec { float x, y, z; };
struct F3D { FVec position, velocity, forward, up; };
typedef int(__stdcall* PCreate)(void**, unsigned);
typedef int(__stdcall* PInit)(void*, int, unsigned, unsigned, void*);
typedef int(__stdcall* PSys)(void*);
typedef int(__stdcall* PLoadBank)(void*, const char*, unsigned, void**);
typedef int(__stdcall* PGetEvent)(void*, const char*, void**);
typedef int(__stdcall* PListener)(void*, int, const F3D*, const FVec*);
typedef int(__stdcall* PCreateInst)(void*, void**);
typedef int(__stdcall* PInst)(void*);
typedef int(__stdcall* PStop)(void*, int);
typedef int(__stdcall* PSet3D)(void*, const F3D*);
typedef int(__stdcall* PSetParam)(void*, const char*, float, int);
typedef int(__stdcall* PGetState)(void*, int*);
typedef int(__stdcall* PSetFloat)(void*, float);
typedef int(__stdcall* PBankCount)(void*, int*);
typedef int(__stdcall* PBankList)(void*, void**, int, int*);
typedef int(__stdcall* PDescPath)(void*, char*, int, int*);
struct FParamId { unsigned d1, d2; };
struct FParamDesc { const char* name; FParamId id; float minimum, maximum, defaultvalue; int type; unsigned flags; unsigned guid[4]; };
typedef int(__stdcall* PParamCount)(void*, int*);
typedef int(__stdcall* PParamByIndex)(void*, int, FParamDesc*);
typedef int(__stdcall* PParamLabel)(void*, int, int, char*, int, int*);
PCreate fCreate; PInit fInit; PSys fUpdate, fRelease; PLoadBank fLoadBank; PGetEvent fGetEvent; PListener fListener;
typedef int(__stdcall* PSetBool)(void*, int);
PCreateInst fCreateInst; PInst fStart, fRelInst; PStop fStop; PSet3D fSet3D; PSetParam fSetParam; PGetState fGetState; PSetFloat fSetVolume; PSetBool fSetPaused;
PBankCount fBankCount; PBankList fBankList; PDescPath fDescPath;
F3D at(float x, float y, float z, float vx, float vy) { // 0047b600: position in pixels, velocity = the movement since the last update, forward +z, up +y
F3D a{};
a.position = {x, y, z};
a.velocity = {vx, vy, 0.0f};
a.forward = {0.0f, 0.0f, 1.0f};
a.up = {0.0f, 1.0f, 0.0f};
return a;
}
} // namespace
int main(int argc, char** argv) {
if (argc < 4) return 2;
const std::string dir = argv[1];
HANDLE map = OpenFileMappingA(FILE_MAP_ALL_ACCESS, FALSE, argv[2]);
if (!map) return 3;
auto* sh = (Shared*)MapViewOfFile(map, FILE_MAP_ALL_ACCESS, 0, 0, sizeof(Shared));
if (!sh || sh->magic != kMagic) return 4;
HANDLE game = OpenProcess(SYNCHRONIZE, FALSE, (DWORD)std::strtoul(argv[3], nullptr, 10));
auto fail = [&](const char* m) { std::snprintf(sh->error, sizeof sh->error, "%s", m); sh->ready = -1; return 5; };
SetDllDirectoryA(dir.c_str()); // fmodstudio.dll needs fmod.dll next to it
HMODULE core = LoadLibraryA((dir + "\\fmod.dll").c_str());
HMODULE studio = LoadLibraryA((dir + "\\fmodstudio.dll").c_str());
if (!core || !studio) return fail("fmod.dll / fmodstudio.dll not found in the Noita folder");
#define GET(v, T, n) v = (T)GetProcAddress(studio, n); if (!v) return fail("missing " n);
GET(fCreate, PCreate, "FMOD_Studio_System_Create");
GET(fInit, PInit, "FMOD_Studio_System_Initialize");
GET(fUpdate, PSys, "FMOD_Studio_System_Update");
GET(fRelease, PSys, "FMOD_Studio_System_Release");
GET(fLoadBank, PLoadBank, "FMOD_Studio_System_LoadBankFile");
GET(fGetEvent, PGetEvent, "FMOD_Studio_System_GetEvent");
GET(fListener, PListener, "FMOD_Studio_System_SetListenerAttributes");
GET(fCreateInst, PCreateInst, "FMOD_Studio_EventDescription_CreateInstance");
GET(fStart, PInst, "FMOD_Studio_EventInstance_Start");
GET(fRelInst, PInst, "FMOD_Studio_EventInstance_Release");
GET(fStop, PStop, "FMOD_Studio_EventInstance_Stop");
GET(fSet3D, PSet3D, "FMOD_Studio_EventInstance_Set3DAttributes");
GET(fSetParam, PSetParam, "FMOD_Studio_EventInstance_SetParameterByName");
GET(fGetState, PGetState, "FMOD_Studio_EventInstance_GetPlaybackState");
GET(fSetVolume, PSetFloat, "FMOD_Studio_EventInstance_SetVolume");
GET(fSetPaused, PSetBool, "FMOD_Studio_EventInstance_SetPaused");
GET(fBankCount, PBankCount, "FMOD_Studio_Bank_GetEventCount");
GET(fBankList, PBankList, "FMOD_Studio_Bank_GetEventList");
GET(fDescPath, PDescPath, "FMOD_Studio_EventDescription_GetPath");
#undef GET
void* sys = nullptr;
if (fCreate(&sys, 0x00020105) != 0 || !sys) return fail("FMOD_Studio_System_Create failed");
if (fInit(sys, 512, 0, 0, nullptr) != 0) return fail("FMOD_Studio_System_Initialize failed");
std::vector<void*> banks;
{ // every bank of data/audio/Desktop (the strings bank gives the event paths)
WIN32_FIND_DATAA fd;
const std::string bdir = dir + "\\data\\audio\\Desktop\\";
HANDLE fh = FindFirstFileA((bdir + "*.bank").c_str(), &fd);
if (fh == INVALID_HANDLE_VALUE) return fail("no banks in data/audio/Desktop");
do { void* b = nullptr; if (fLoadBank(sys, (bdir + fd.cFileName).c_str(), 0, &b) == 0) banks.push_back(b); } while (FindNextFileA(fh, &fd));
FindClose(fh);
}
if (std::getenv("NG_AUDIO_LIST")) { // every event path, for the tables
FILE* f = std::fopen(std::getenv("NG_AUDIO_LIST"), "w");
for (void* b : banks) {
int n = 0; fBankCount(b, &n);
std::vector<void*> d(n > 0 ? n : 1); int got = 0; fBankList(b, d.data(), n, &got);
for (int i = 0; i < got; ++i) { char p[512]; int r = 0; if (fDescPath(d[i], p, sizeof p, &r) == 0 && f) std::fprintf(f, "%s\n", p); }
}
if (f) std::fclose(f);
}
if (const char* dn = std::getenv("NG_AUDIO_DESCRIBE")) { // the parameters of some events (path;path;...), for the tables: NG_AUDIO_DESCRIBE_OUT
auto pc = (PParamCount)GetProcAddress(studio, "FMOD_Studio_EventDescription_GetParameterDescriptionCount");
auto pb = (PParamByIndex)GetProcAddress(studio, "FMOD_Studio_EventDescription_GetParameterDescriptionByIndex");
auto pl = (PParamLabel)GetProcAddress(studio, "FMOD_Studio_EventDescription_GetParameterLabelByIndex");
FILE* f = std::fopen(std::getenv("NG_AUDIO_DESCRIBE_OUT") ? std::getenv("NG_AUDIO_DESCRIBE_OUT") : "describe.txt", "w");
std::string list = dn;
size_t p0 = 0;
while (f && p0 <= list.size()) {
size_t e = list.find(';', p0); if (e == std::string::npos) e = list.size();
const std::string path = list.substr(p0, e - p0); p0 = e + 1;
void* d = nullptr;
if (path.empty() || fGetEvent(sys, path.c_str(), &d) != 0) { std::fprintf(f, "%s: missing\n", path.c_str()); continue; }
int n = 0; if (pc) pc(d, &n);
std::fprintf(f, "%s: %d parameters\n", path.c_str(), n);
for (int i = 0; i < n && pb; ++i) {
FParamDesc pdsc{}; pb(d, i, &pdsc);
std::fprintf(f, " %s [%g .. %g] default %g type %d flags %u\n", pdsc.name ? pdsc.name : "?", pdsc.minimum, pdsc.maximum, pdsc.defaultvalue, pdsc.type, pdsc.flags);
if (pl && (pdsc.flags & 0x20)) for (int k = 0; k < 64; ++k) { char lb[128]; int r = 0; if (pl(d, i, k, lb, sizeof lb, &r) != 0) break; std::fprintf(f, " label %d: %s\n", k, lb); }
}
}
if (f) std::fclose(f);
}
{ // every event path, for the game (the music's track lists)
size_t pos = 0;
for (void* b : banks) {
int n = 0; fBankCount(b, &n);
std::vector<void*> d(n > 0 ? n : 1); int got = 0; fBankList(b, d.data(), n, &got);
for (int i = 0; i < got; ++i) {
char pth[512]; int r = 0;
if (fDescPath(d[i], pth, sizeof pth, &r) != 0) continue;
const size_t l = std::strlen(pth);
if (pos + l + 2 >= sizeof sh->events) break;
std::memcpy(sh->events + pos, pth, l); pos += l; sh->events[pos++] = 10;
}
}
sh->events[pos] = 0;
}
sh->ready = 1;
FILE* alog = std::getenv("NG_AUDIOLOG") ? std::fopen(std::getenv("NG_AUDIOLOG"), "w") : nullptr; // (tests: what was played, what was missing)
std::map<std::string, void*> descs;
auto describe = [&](const char* path) -> void* {
auto it = descs.find(path);
if (it != descs.end()) return it->second;
void* d = nullptr;
if (fGetEvent(sys, path, &d) != 0) d = nullptr;
descs[path] = d;
return d;
};
struct Kept { void* inst = nullptr; float x = 0, y = 0; };
std::vector<Kept> kept(kHandles);
std::vector<void*> oneshots;
for (;;) {
if (game && WaitForSingleObject(game, 0) == WAIT_OBJECT_0) break;
bool quit = false;
while (sh->tail != sh->head) {
const Cmd c = sh->ring[sh->tail % kRing];
MemoryBarrier();
sh->tail = sh->tail + 1;
const int h = ((c.handle % kHandles) + kHandles) % kHandles;
switch (c.op) {
case PlayOneShot: case Start: {
void* d = describe(c.name);
void* inst = nullptr;
if (alog) { std::fprintf(alog, "%s %s (%.0f, %.0f)\n", d ? (c.op == Start ? "start" : "play") : "MISSING", c.name, c.x, c.y); std::fflush(alog); }
if (!d || fCreateInst(d, &inst) != 0 || !inst) break;
const F3D a = at(c.x, c.y, c.z, 0, 0);
fSet3D(inst, &a);
if (c.value > 0.0f && c.value != 1.0f) fSetVolume(inst, c.value);
fStart(inst);
if (c.op == PlayOneShot) fRelInst(inst); // (released: it frees itself when it ends)
else { if (kept[h].inst) { fStop(kept[h].inst, 0); fRelInst(kept[h].inst); } kept[h] = {inst, c.x, c.y}; sh->playing[h] = 1; }
break;
}
case Move: if (kept[h].inst) { const F3D a = at(c.x, c.y, c.z, c.x - kept[h].x, c.y - kept[h].y); fSet3D(kept[h].inst, &a); kept[h].x = c.x; kept[h].y = c.y; } break;
case SetParam: if (kept[h].inst) fSetParam(kept[h].inst, c.name, c.value, 0); break;
case SetVolume: if (kept[h].inst) fSetVolume(kept[h].inst, c.value); break;
case Pause: for (int k = 0; k < kHandles; ++k) if (kept[k].inst) fSetPaused(kept[k].inst, c.value != 0.0f ? 1 : 0); break; // (the pause menu: the loops and the music hold)
case Stop: case StopNow: if (kept[h].inst) { fStop(kept[h].inst, c.op == StopNow ? 1 : 0); fRelInst(kept[h].inst); kept[h].inst = nullptr; sh->playing[h] = 0; } break;
case Listener: { const F3D a = at(c.x, c.y, c.z, 0, 0); const FVec p{c.x, c.y, c.z}; fListener(sys, 0, &a, &p); break; }
case Release: if (kept[h].inst) { fRelInst(kept[h].inst); kept[h].inst = nullptr; sh->playing[h] = 0; } break; // (it plays to its end on its own)
case Quit: quit = true; break;
default: break;
}
}
if (quit) break;
for (int h = 0; h < kHandles; ++h)
if (kept[h].inst) { int st = 2; fGetState(kept[h].inst, &st); if (st == 2) { fRelInst(kept[h].inst); kept[h].inst = nullptr; sh->playing[h] = 0; } }
fUpdate(sys);
Sleep(8);
}
fRelease(sys);
return 0;
}