init
This commit is contained in:
commit
87bff164f1
416 files changed
+279512
No files matched your search
+544
@@ -0,0 +1,544 @@
|
||||
#pragma once
|
||||
// Replay recorder: keeps the last N presented frames (compressed with Blosc2/Zstd in a worker thread) and writes them to disk on request: every frame as a PNG
|
||||
// (real deflate), an animated GIF, a contact sheet and a text file with the view / player position of every frame, so that a bug that only shows in motion can be
|
||||
// looked at afterwards. Capture: the host copies the final output image into a host-visible buffer each frame and hands it to push() one frame later.
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include <blosc2.h>
|
||||
|
||||
namespace ng {
|
||||
|
||||
struct ClipMeta { int frame = 0, view_x = 0, view_y = 0; double px = 0, py = 0; double time_ms = 0; float scale = 1, psx = 0, psy = 0; }; // scale: output pixels per cell, psx / psy: the player on the screen (output pixels)
|
||||
struct ClipFrame { int w = 0, h = 0; ClipMeta meta; std::vector<uint8_t> comp; }; // one recorded frame: BGRA w*h*4, compressed
|
||||
|
||||
// ---------- PNG with a real (fixed Huffman + LZ77) deflate, thread safe ----------
|
||||
namespace clipimpl {
|
||||
|
||||
inline uint32_t crc32(const uint8_t* p, size_t n, uint32_t crc = 0xFFFFFFFFu) {
|
||||
static const auto table = [] {
|
||||
struct T { uint32_t v[256]; } t;
|
||||
for (uint32_t i = 0; i < 256; ++i) { uint32_t c = i; for (int k = 0; k < 8; ++k) c = (c & 1) ? 0xEDB88320u ^ (c >> 1) : c >> 1; t.v[i] = c; }
|
||||
return t;
|
||||
}();
|
||||
for (size_t i = 0; i < n; ++i) crc = table.v[(crc ^ p[i]) & 0xFF] ^ (crc >> 8);
|
||||
return crc;
|
||||
}
|
||||
|
||||
struct BitWriter {
|
||||
std::vector<uint8_t>& out;
|
||||
uint32_t acc = 0; int nb = 0;
|
||||
explicit BitWriter(std::vector<uint8_t>& o) : out(o) {}
|
||||
void bits(uint32_t v, int n) { acc |= v << nb; nb += n; while (nb >= 8) { out.push_back((uint8_t)acc); acc >>= 8; nb -= 8; } }
|
||||
void huff(uint32_t code, int len) { uint32_t r = 0; for (int i = 0; i < len; ++i) r |= ((code >> i) & 1u) << (len - 1 - i); bits(r, len); } // codes go out most significant bit first
|
||||
void flush() { if (nb > 0) { out.push_back((uint8_t)acc); acc = 0; nb = 0; } }
|
||||
};
|
||||
|
||||
inline void put_lit(BitWriter& w, int c) {
|
||||
if (c < 144) w.huff(0x30 + c, 8);
|
||||
else if (c < 256) w.huff(0x190 + c - 144, 9);
|
||||
else if (c < 280) w.huff(c - 256, 7);
|
||||
else w.huff(0xC0 + c - 280, 8);
|
||||
}
|
||||
|
||||
inline std::vector<uint8_t> deflate_fixed(const std::vector<uint8_t>& in) {
|
||||
static const uint16_t lbase[29] = {3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258};
|
||||
static const uint8_t lext[29] = {0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0};
|
||||
static const uint16_t dbase[30] = {1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577};
|
||||
static const uint8_t dext[30] = {0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13};
|
||||
std::vector<uint8_t> out;
|
||||
out.reserve(in.size() / 4 + 64);
|
||||
out.push_back(0x78); out.push_back(0x01);
|
||||
BitWriter w(out);
|
||||
w.bits(1, 1); w.bits(1, 2); // final block, fixed Huffman
|
||||
const size_t n = in.size();
|
||||
std::vector<int32_t> head(1 << 15, -1), prev(n, -1);
|
||||
auto hash = [&](size_t i) { return (uint32_t)((in[i] * 506832829u ^ in[i + 1] * 2654435761u ^ in[i + 2] * 40503u) >> 4) & 0x7FFFu; };
|
||||
size_t i = 0;
|
||||
while (i < n) {
|
||||
int bestLen = 0, bestDist = 0;
|
||||
if (i + 3 <= n) {
|
||||
const uint32_t h = hash(i);
|
||||
int32_t cand = head[h];
|
||||
for (int depth = 0; cand >= 0 && depth < 24 && i - (size_t)cand <= 32768; ++depth, cand = prev[(size_t)cand]) {
|
||||
const size_t maxl = std::min<size_t>(258, n - i);
|
||||
size_t l = 0;
|
||||
while (l < maxl && in[(size_t)cand + l] == in[i + l]) ++l;
|
||||
if ((int)l > bestLen) { bestLen = (int)l; bestDist = (int)(i - (size_t)cand); if (l == maxl) break; }
|
||||
}
|
||||
prev[i] = head[h]; head[h] = (int32_t)i;
|
||||
}
|
||||
if (bestLen >= 3) {
|
||||
int lc = 28; while (lbase[lc] > bestLen) --lc;
|
||||
put_lit(w, 257 + lc);
|
||||
if (lext[lc]) w.bits((uint32_t)(bestLen - lbase[lc]), lext[lc]);
|
||||
int dc = 29; while (dbase[dc] > bestDist) --dc;
|
||||
w.huff((uint32_t)dc, 5);
|
||||
if (dext[dc]) w.bits((uint32_t)(bestDist - dbase[dc]), dext[dc]);
|
||||
for (int k = 1; k < bestLen; ++k) { const size_t j = i + (size_t)k; if (j + 3 <= n) { const uint32_t h = hash(j); prev[j] = head[h]; head[h] = (int32_t)j; } }
|
||||
i += (size_t)bestLen;
|
||||
} else { put_lit(w, in[i]); ++i; }
|
||||
}
|
||||
put_lit(w, 256);
|
||||
w.flush();
|
||||
uint32_t a = 1, b = 0;
|
||||
for (uint8_t v : in) { a = (a + v) % 65521; b = (b + a) % 65521; }
|
||||
const uint32_t ad = (b << 16) | a;
|
||||
for (int s = 24; s >= 0; s -= 8) out.push_back((ad >> s) & 0xFF);
|
||||
return out;
|
||||
}
|
||||
|
||||
inline bool write_png(const std::string& path, const uint8_t* rgb, int w, int h) {
|
||||
std::vector<uint8_t> raw;
|
||||
raw.reserve((size_t)h * (w * 3 + 1));
|
||||
for (int y = 0; y < h; ++y) { // filter 1 (sub) per row
|
||||
const uint8_t* row = rgb + (size_t)y * w * 3;
|
||||
raw.push_back(1);
|
||||
for (int x = 0; x < w * 3; ++x) raw.push_back((uint8_t)(row[x] - (x >= 3 ? row[x - 3] : 0)));
|
||||
}
|
||||
const std::vector<uint8_t> z = deflate_fixed(raw);
|
||||
FILE* f = nullptr;
|
||||
if (fopen_s(&f, path.c_str(), "wb") != 0 || !f) return false;
|
||||
auto be32 = [](std::vector<uint8_t>& v, uint32_t x) { for (int s = 24; s >= 0; s -= 8) v.push_back((x >> s) & 0xFF); };
|
||||
auto chunk = [&](const char* type, const std::vector<uint8_t>& data) {
|
||||
std::vector<uint8_t> c;
|
||||
be32(c, (uint32_t)data.size());
|
||||
c.insert(c.end(), type, type + 4);
|
||||
c.insert(c.end(), data.begin(), data.end());
|
||||
be32(c, ~crc32(c.data() + 4, c.size() - 4));
|
||||
fwrite(c.data(), 1, c.size(), f);
|
||||
};
|
||||
static const uint8_t sig[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
fwrite(sig, 1, 8, f);
|
||||
std::vector<uint8_t> ihdr;
|
||||
be32(ihdr, (uint32_t)w); be32(ihdr, (uint32_t)h);
|
||||
ihdr.insert(ihdr.end(), {8, 2, 0, 0, 0});
|
||||
chunk("IHDR", ihdr); chunk("IDAT", z); chunk("IEND", {});
|
||||
return fclose(f) == 0;
|
||||
}
|
||||
|
||||
// ---------- GIF (median cut palette, LZW) ----------
|
||||
struct Palette { uint8_t rgb[256][3]; int n = 0; std::vector<uint8_t> lut; }; // lut: 15-bit colour -> index
|
||||
|
||||
inline Palette make_palette(const std::vector<const uint8_t*>& imgs, int w, int h) { // imgs: RGB w*h*3
|
||||
struct Box { std::vector<uint32_t> px; }; // packed 0xRRGGBB
|
||||
std::vector<uint32_t> all;
|
||||
for (const uint8_t* im : imgs) for (size_t i = 0; i < (size_t)w * h; i += 7) all.push_back((uint32_t)im[i * 3] << 16 | (uint32_t)im[i * 3 + 1] << 8 | im[i * 3 + 2]);
|
||||
std::vector<Box> boxes(1);
|
||||
boxes[0].px = std::move(all);
|
||||
while ((int)boxes.size() < 256) {
|
||||
size_t bi = 0, best = 0;
|
||||
for (size_t k = 0; k < boxes.size(); ++k) if (boxes[k].px.size() > best) { best = boxes[k].px.size(); bi = k; }
|
||||
if (best < 2) break;
|
||||
Box& b = boxes[bi];
|
||||
int lo[3] = {255, 255, 255}, hi[3] = {0, 0, 0};
|
||||
for (uint32_t p : b.px) for (int c = 0; c < 3; ++c) { const int v = (p >> (16 - 8 * c)) & 255; lo[c] = std::min(lo[c], v); hi[c] = std::max(hi[c], v); }
|
||||
int axis = 0; for (int c = 1; c < 3; ++c) if (hi[c] - lo[c] > hi[axis] - lo[axis]) axis = c;
|
||||
if (hi[axis] == lo[axis]) { bool any = false; for (size_t k = 0; k < boxes.size(); ++k) if (k != bi && boxes[k].px.size() > 1) any = true; if (!any) break; boxes[bi].px.shrink_to_fit(); Box tmp; tmp.px.assign(1, b.px[0]); std::vector<uint32_t> rest(b.px.begin() + 1, b.px.end()); b.px = std::move(rest); boxes.push_back(std::move(tmp)); continue; }
|
||||
const int sh = 16 - 8 * axis;
|
||||
std::sort(b.px.begin(), b.px.end(), [&](uint32_t x, uint32_t y) { return ((x >> sh) & 255) < ((y >> sh) & 255); });
|
||||
Box nb;
|
||||
nb.px.assign(b.px.begin() + b.px.size() / 2, b.px.end());
|
||||
b.px.resize(b.px.size() / 2);
|
||||
boxes.push_back(std::move(nb));
|
||||
}
|
||||
Palette pal;
|
||||
for (const Box& b : boxes) {
|
||||
if (b.px.empty() || pal.n >= 256) continue;
|
||||
uint64_t s[3] = {0, 0, 0};
|
||||
for (uint32_t p : b.px) for (int c = 0; c < 3; ++c) s[c] += (p >> (16 - 8 * c)) & 255;
|
||||
for (int c = 0; c < 3; ++c) pal.rgb[pal.n][c] = (uint8_t)(s[c] / b.px.size());
|
||||
++pal.n;
|
||||
}
|
||||
pal.lut.assign(32768, 0);
|
||||
for (int k = 0; k < 32768; ++k) {
|
||||
const int r = ((k >> 10) & 31) * 255 / 31, g = ((k >> 5) & 31) * 255 / 31, bl = (k & 31) * 255 / 31;
|
||||
int bestd = 1 << 30, bi = 0;
|
||||
for (int p = 0; p < pal.n; ++p) { const int dr = r - pal.rgb[p][0], dg = g - pal.rgb[p][1], db = bl - pal.rgb[p][2]; const int d = dr * dr + dg * dg + db * db; if (d < bestd) { bestd = d; bi = p; } }
|
||||
pal.lut[(size_t)k] = (uint8_t)bi;
|
||||
}
|
||||
return pal;
|
||||
}
|
||||
|
||||
inline void gif_lzw(std::vector<uint8_t>& out, const std::vector<uint8_t>& idx) {
|
||||
out.push_back(8); // minimum code size
|
||||
std::vector<uint8_t> blk;
|
||||
uint32_t acc = 0; int nb = 0;
|
||||
auto emit = [&](uint32_t code, int bits) { acc |= code << nb; nb += bits; while (nb >= 8) { blk.push_back((uint8_t)acc); acc >>= 8; nb -= 8; } };
|
||||
std::unordered_map<uint32_t, uint16_t> dict;
|
||||
int next = 258, bits = 9;
|
||||
emit(256, bits);
|
||||
uint32_t cur = idx.empty() ? 0 : idx[0];
|
||||
for (size_t i = 1; i < idx.size(); ++i) {
|
||||
const uint32_t key = cur << 8 | idx[i];
|
||||
auto it = dict.find(key);
|
||||
if (it != dict.end()) { cur = it->second; continue; }
|
||||
emit(cur, bits);
|
||||
if (next < 4096) { dict[key] = (uint16_t)next++; if (next > (1 << bits) && bits < 12) ++bits; }
|
||||
else { emit(256, bits); dict.clear(); next = 258; bits = 9; }
|
||||
cur = idx[i];
|
||||
}
|
||||
if (!idx.empty()) emit(cur, bits);
|
||||
emit(257, bits);
|
||||
if (nb > 0) blk.push_back((uint8_t)acc);
|
||||
for (size_t p = 0; p < blk.size(); p += 255) { const size_t c = std::min<size_t>(255, blk.size() - p); out.push_back((uint8_t)c); out.insert(out.end(), blk.begin() + p, blk.begin() + p + c); }
|
||||
out.push_back(0);
|
||||
}
|
||||
|
||||
inline std::mutex& blosc_mutex() { static std::mutex m; return m; }
|
||||
|
||||
// The BGRA pixels of a recorded frame.
|
||||
inline bool decode_bgra(const ClipFrame& f, std::vector<uint8_t>& bgra) {
|
||||
bgra.resize((size_t)f.w * f.h * 4);
|
||||
std::lock_guard<std::mutex> bl(blosc_mutex());
|
||||
return blosc2_decompress(f.comp.data(), (int32_t)f.comp.size(), bgra.data(), (int32_t)bgra.size()) == (int)bgra.size();
|
||||
}
|
||||
|
||||
// The rectangle (x, y, w, h) of BGRA src (sw wide) to RGB dst of dw x dh, every output pixel the mean of the source pixels it covers.
|
||||
inline void crop_scale_rgb(const uint8_t* src, int sw, int x, int y, int w, int h, int dw, int dh, std::vector<uint8_t>& dst) {
|
||||
dst.resize((size_t)dw * dh * 3);
|
||||
std::vector<int> xs(dw + 1);
|
||||
for (int i = 0; i <= dw; ++i) xs[i] = x + (int)((int64_t)i * w / dw);
|
||||
for (int j = 0; j < dh; ++j) {
|
||||
const int y0 = y + (int)((int64_t)j * h / dh), y1 = std::max(y0 + 1, y + (int)((int64_t)(j + 1) * h / dh));
|
||||
for (int i = 0; i < dw; ++i) {
|
||||
const int x0 = xs[i], x1 = std::max(x0 + 1, xs[i + 1]);
|
||||
uint32_t s[3] = {0, 0, 0};
|
||||
for (int yy = y0; yy < y1; ++yy) { const uint8_t* p = src + ((size_t)yy * sw + x0) * 4; for (int xx = x0; xx < x1; ++xx, p += 4) { s[0] += p[2]; s[1] += p[1]; s[2] += p[0]; } }
|
||||
const uint32_t n = (uint32_t)((y1 - y0) * (x1 - x0));
|
||||
uint8_t* d = &dst[((size_t)j * dw + i) * 3];
|
||||
d[0] = (uint8_t)(s[0] / n); d[1] = (uint8_t)(s[1] / n); d[2] = (uint8_t)(s[2] / n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The palette of one image (rgb, n pixels) for a GIF local colour table: a histogram of all its pixels (6 bits per channel), cut into boxes, always the box with
|
||||
// the largest squared error, at the weighted mean of its widest channel. A small patch of a colour far from the rest (the player's robe, a spark) then gets an
|
||||
// entry of its own instead of being averaged into a large box, as the plain median cut by pixel count does; an image with at most 256 colours keeps them all.
|
||||
// No lut is made (GifWriter finds the nearest entry of the colours it meets).
|
||||
inline Palette make_palette_frame(const uint8_t* rgb, size_t n) {
|
||||
std::vector<uint32_t> hist(1u << 18, 0);
|
||||
for (size_t i = 0; i < n; ++i) ++hist[(uint32_t)(rgb[i * 3] >> 2) << 12 | (uint32_t)(rgb[i * 3 + 1] >> 2) << 6 | (uint32_t)(rgb[i * 3 + 2] >> 2)];
|
||||
struct E { uint32_t c, n; };
|
||||
std::vector<E> es;
|
||||
for (uint32_t c = 0; c < hist.size(); ++c) if (hist[c]) es.push_back({c, hist[c]});
|
||||
auto ch = [](uint32_t c, int k) { const uint32_t v = (c >> (12 - 6 * k)) & 63; return (double)(v << 2 | v >> 4); };
|
||||
struct Box { size_t b, e; double n, s[3], sse; };
|
||||
auto stats = [&](size_t b, size_t e) {
|
||||
Box bx{b, e, 0, {0, 0, 0}, 0};
|
||||
double s2 = 0;
|
||||
for (size_t i = b; i < e; ++i) for (int k = 0; k < 3; ++k) { const double v = ch(es[i].c, k); bx.s[k] += v * es[i].n; s2 += v * v * es[i].n; }
|
||||
for (size_t i = b; i < e; ++i) bx.n += es[i].n;
|
||||
bx.sse = s2 - (bx.s[0] * bx.s[0] + bx.s[1] * bx.s[1] + bx.s[2] * bx.s[2]) / std::max(1.0, bx.n);
|
||||
return bx;
|
||||
};
|
||||
std::vector<Box> boxes;
|
||||
if (!es.empty()) boxes.push_back(stats(0, es.size()));
|
||||
while (boxes.size() < 256) {
|
||||
size_t bi = 0;
|
||||
for (size_t k = 1; k < boxes.size(); ++k) if (boxes[k].sse > boxes[bi].sse) bi = k;
|
||||
const Box bx = boxes[bi];
|
||||
if (bx.sse <= 0.5 || bx.e - bx.b < 2) break;
|
||||
int axis = 0; double best = -1;
|
||||
for (int k = 0; k < 3; ++k) {
|
||||
const double m = bx.s[k] / bx.n;
|
||||
double var = 0;
|
||||
for (size_t i = bx.b; i < bx.e; ++i) { const double d = ch(es[i].c, k) - m; var += d * d * es[i].n; }
|
||||
if (var > best) { best = var; axis = k; }
|
||||
}
|
||||
const double mean = bx.s[axis] / bx.n;
|
||||
const auto mid = std::partition(es.begin() + (std::ptrdiff_t)bx.b, es.begin() + (std::ptrdiff_t)bx.e, [&](const E& x) { return ch(x.c, axis) <= mean; });
|
||||
const size_t m = (size_t)(mid - es.begin());
|
||||
if (m == bx.b || m == bx.e) break;
|
||||
boxes[bi] = stats(bx.b, m);
|
||||
boxes.push_back(stats(m, bx.e));
|
||||
}
|
||||
Palette pal;
|
||||
for (const Box& bx : boxes) {
|
||||
for (int k = 0; k < 3; ++k) pal.rgb[pal.n][k] = (uint8_t)std::clamp((int)std::lround(bx.s[k] / bx.n), 0, 255);
|
||||
++pal.n;
|
||||
}
|
||||
if (pal.n == 0) { pal.rgb[0][0] = pal.rgb[0][1] = pal.rgb[0][2] = 0; pal.n = 1; }
|
||||
return pal;
|
||||
}
|
||||
|
||||
// Writes a looping GIF frame by frame, so that a long clip does not have to be held in memory: with one global palette (made with make_palette, its lut used),
|
||||
// or with a local colour table per frame (open without a palette, add with one from make_palette_frame), optionally with a light ordered (Bayer 4x4) dither.
|
||||
class GifWriter {
|
||||
public:
|
||||
~GifWriter() { if (f_) std::fclose(f_); }
|
||||
bool open(const std::string& path, int w, int h, const Palette* pal) {
|
||||
w_ = w; h_ = h; pal_ = pal; bytes_ = 0;
|
||||
if (fopen_s(&f_, path.c_str(), "wb") != 0 || !f_) { f_ = nullptr; return false; }
|
||||
std::vector<uint8_t> g;
|
||||
const char* hdr = "GIF89a";
|
||||
g.insert(g.end(), hdr, hdr + 6);
|
||||
g.push_back((uint8_t)(w & 255)); g.push_back((uint8_t)(w >> 8)); g.push_back((uint8_t)(h & 255)); g.push_back((uint8_t)(h >> 8));
|
||||
g.push_back(pal ? 0xF7 : 0x70); g.push_back(0); g.push_back(0);
|
||||
if (pal) for (int p = 0; p < 256; ++p) for (int c = 0; c < 3; ++c) g.push_back(p < pal->n ? pal->rgb[p][c] : 0);
|
||||
const uint8_t app[] = {0x21, 0xFF, 0x0B, 'N', 'E', 'T', 'S', 'C', 'A', 'P', 'E', '2', '.', '0', 0x03, 0x01, 0, 0, 0}; // loop forever
|
||||
g.insert(g.end(), app, app + sizeof app);
|
||||
put(g);
|
||||
return true;
|
||||
}
|
||||
// rgb: w*h*3; delay in hundredths of a second; local: the palette of this frame (a local colour table); dither: the span of the ordered dither (0 = none).
|
||||
void add(const uint8_t* rgb, int delay_cs, const Palette* local = nullptr, int dither = 0) {
|
||||
if (!f_ || (!local && !pal_)) return;
|
||||
std::vector<uint8_t> idx((size_t)w_ * h_), g;
|
||||
if (!local) for (size_t i = 0; i < idx.size(); ++i) idx[i] = pal_->lut[(size_t)((rgb[i * 3] >> 3) << 10 | (rgb[i * 3 + 1] >> 3) << 5 | (rgb[i * 3 + 2] >> 3))];
|
||||
else { // the nearest entry of each colour met (6 bits per channel), cached
|
||||
static const int bayer[4][4] = {{0, 8, 2, 10}, {12, 4, 14, 6}, {3, 11, 1, 9}, {15, 7, 13, 5}};
|
||||
std::vector<int16_t> nearest(1u << 18, -1);
|
||||
for (int y = 0; y < h_; ++y) for (int x = 0; x < w_; ++x) {
|
||||
const size_t i = (size_t)y * w_ + x;
|
||||
const int d = dither ? (bayer[y & 3][x & 3] * 2 - 15) * dither / 32 : 0;
|
||||
const int r = std::clamp(rgb[i * 3] + d, 0, 255), gg = std::clamp(rgb[i * 3 + 1] + d, 0, 255), b = std::clamp(rgb[i * 3 + 2] + d, 0, 255);
|
||||
const uint32_t key = (uint32_t)(r >> 2) << 12 | (uint32_t)(gg >> 2) << 6 | (uint32_t)(b >> 2);
|
||||
int16_t& e = nearest[key];
|
||||
if (e < 0) {
|
||||
int bestd = 1 << 30, bi = 0;
|
||||
for (int p = 0; p < local->n; ++p) { const int dr = r - local->rgb[p][0], dg = gg - local->rgb[p][1], db = b - local->rgb[p][2]; const int dd = dr * dr + dg * dg + db * db; if (dd < bestd) { bestd = dd; bi = p; } }
|
||||
e = (int16_t)bi;
|
||||
}
|
||||
idx[i] = (uint8_t)e;
|
||||
}
|
||||
}
|
||||
const uint8_t gce[] = {0x21, 0xF9, 0x04, 0x00, (uint8_t)(delay_cs & 255), (uint8_t)(delay_cs >> 8), 0, 0};
|
||||
g.insert(g.end(), gce, gce + sizeof gce);
|
||||
g.push_back(0x2C); g.push_back(0); g.push_back(0); g.push_back(0); g.push_back(0);
|
||||
g.push_back((uint8_t)(w_ & 255)); g.push_back((uint8_t)(w_ >> 8)); g.push_back((uint8_t)(h_ & 255)); g.push_back((uint8_t)(h_ >> 8));
|
||||
if (local) { g.push_back(0x87); for (int p = 0; p < 256; ++p) for (int c = 0; c < 3; ++c) g.push_back(p < local->n ? local->rgb[p][c] : 0); } // a local colour table of 256 entries
|
||||
else g.push_back(0);
|
||||
gif_lzw(g, idx);
|
||||
put(g);
|
||||
}
|
||||
// Returns the size of the file, 0 if it could not be written.
|
||||
size_t close() {
|
||||
if (!f_) return 0;
|
||||
const uint8_t end = 0x3B;
|
||||
std::fwrite(&end, 1, 1, f_); ++bytes_;
|
||||
const bool ok = std::fclose(f_) == 0 && !failed_;
|
||||
f_ = nullptr;
|
||||
return ok ? bytes_ : 0;
|
||||
}
|
||||
private:
|
||||
void put(const std::vector<uint8_t>& g) { if (std::fwrite(g.data(), 1, g.size(), f_) != g.size()) failed_ = true; bytes_ += g.size(); }
|
||||
FILE* f_ = nullptr;
|
||||
int w_ = 0, h_ = 0;
|
||||
const Palette* pal_ = nullptr;
|
||||
size_t bytes_ = 0;
|
||||
bool failed_ = false;
|
||||
};
|
||||
|
||||
// The frames of a GIF from the frame times (ms): every step-th frame of [in, out], each shown until the next one picked, in whole hundredths rounded on the running
|
||||
// clock so that the GIF keeps the real speed. A frame that would last less than 2 cs is left out (browsers show such frames at 10 cs), the one before it lasts longer.
|
||||
struct GifPick { int index = 0; int delay_cs = 2; };
|
||||
inline std::vector<GifPick> gif_pick(const std::vector<double>& t_ms, int in, int out, int step) {
|
||||
std::vector<GifPick> picks;
|
||||
if (t_ms.empty() || in > out) return picks;
|
||||
step = std::max(1, step);
|
||||
const double per = out > in ? (t_ms[(size_t)out] - t_ms[(size_t)in]) / (out - in) : 1000.0 / 60.0; // the mean frame time (the last frame lasts that long times the step)
|
||||
int prevCs = 0;
|
||||
for (int i = in; i <= out; i += step) {
|
||||
const int cs = (int)std::lround((t_ms[(size_t)i] - t_ms[(size_t)in]) / 10.0);
|
||||
if (!picks.empty()) { if (cs - prevCs < 2) continue; picks.back().delay_cs = cs - prevCs; }
|
||||
GifPick p; p.index = i; picks.push_back(p);
|
||||
prevCs = cs;
|
||||
}
|
||||
const int last = picks.back().index;
|
||||
const int endCs = (int)std::lround((t_ms[(size_t)last] - t_ms[(size_t)in] + per * step) / 10.0);
|
||||
picks.back().delay_cs = std::max(2, endCs - prevCs);
|
||||
return picks;
|
||||
}
|
||||
|
||||
} // namespace clipimpl
|
||||
|
||||
class ClipRecorder {
|
||||
public:
|
||||
explicit ClipRecorder(size_t max_frames) : max_(max_frames) { blosc2_init(); worker_ = std::thread([this] { run(); }); }
|
||||
~ClipRecorder() {
|
||||
{ std::lock_guard<std::mutex> l(mu_); stop_ = true; }
|
||||
cv_.notify_all();
|
||||
if (worker_.joinable()) worker_.join();
|
||||
for (auto& t : writers_) if (t.joinable()) t.join();
|
||||
}
|
||||
size_t frames() { std::lock_guard<std::mutex> l(ringMu_); return ring_.size(); }
|
||||
size_t dropped() const { return dropped_.load(); }
|
||||
size_t bytes() { std::lock_guard<std::mutex> l(ringMu_); size_t b = 0; for (const auto& f : ring_) b += f->comp.size(); return b; } // the compressed size of the ring
|
||||
// The frames of the ring now (oldest first); they stay valid while the ring moves on.
|
||||
std::vector<std::shared_ptr<const ClipFrame>> snapshot() { std::lock_guard<std::mutex> l(ringMu_); return std::vector<std::shared_ptr<const ClipFrame>>(ring_.begin(), ring_.end()); }
|
||||
|
||||
// bgra: w*h*4 bytes of the output image (blue first), read by the worker thread straight from where it is (a mapped buffer): `busy` is true until it is done with it.
|
||||
void push_ref(const uint8_t* bgra, int w, int h, const ClipMeta& m, std::atomic<bool>* busy) {
|
||||
std::lock_guard<std::mutex> l(mu_);
|
||||
if (queue_.size() >= 6) { ++dropped_; busy->store(false); return; }
|
||||
Job j; j.w = w; j.h = h; j.meta = m; j.ref = bgra; j.busy = busy;
|
||||
queue_.push_back(std::move(j));
|
||||
cv_.notify_one();
|
||||
}
|
||||
|
||||
// Writes the frames of the ring to <base>/clip_<time>/ on a background thread; returns the folder.
|
||||
std::string dump(const std::string& base) {
|
||||
std::vector<std::shared_ptr<Frame>> snap;
|
||||
{ std::lock_guard<std::mutex> l(ringMu_); snap.assign(ring_.begin(), ring_.end()); }
|
||||
const std::time_t t = std::time(nullptr);
|
||||
std::tm tmv{};
|
||||
localtime_s(&tmv, &t);
|
||||
char name[64];
|
||||
std::snprintf(name, sizeof name, "clip_%04d%02d%02d_%02d%02d%02d", tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
|
||||
const std::string dir = (std::filesystem::path(base) / name).string();
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
writers_.emplace_back([this, snap = std::move(snap), dir] { write_all(snap, dir); });
|
||||
return dir;
|
||||
}
|
||||
|
||||
private:
|
||||
using Frame = ClipFrame;
|
||||
struct Job { int w = 0, h = 0; ClipMeta meta; const uint8_t* ref = nullptr; std::atomic<bool>* busy = nullptr; };
|
||||
|
||||
static std::mutex& bloscMutex() { return clipimpl::blosc_mutex(); }
|
||||
|
||||
void run() {
|
||||
for (;;) {
|
||||
Job j;
|
||||
{
|
||||
std::unique_lock<std::mutex> l(mu_);
|
||||
cv_.wait(l, [this] { return stop_ || !queue_.empty(); });
|
||||
if (queue_.empty()) return;
|
||||
j = std::move(queue_.front());
|
||||
queue_.pop_front();
|
||||
}
|
||||
auto f = std::make_shared<Frame>();
|
||||
f->w = j.w; f->h = j.h; f->meta = j.meta;
|
||||
// a window wider than 1600 px is recorded at half size (2 x 2 average): at 2560 x 1440 a frame is 14.7 MB raw and explosions compress
|
||||
// badly, so 1200 frames grew to 10 - 17 GB and the compression fell behind (the GIFs of the editor are small anyway)
|
||||
const uint8_t* src = j.ref;
|
||||
if (j.w > 1600 && j.h >= 2) {
|
||||
const int hw = j.w / 2, hh = j.h / 2;
|
||||
half_.resize((size_t)hw * hh * 4);
|
||||
for (int y = 0; y < hh; ++y) {
|
||||
const uint8_t* r0 = j.ref + (size_t)(2 * y) * j.w * 4;
|
||||
const uint8_t* r1 = r0 + (size_t)j.w * 4;
|
||||
uint8_t* d = &half_[(size_t)y * hw * 4];
|
||||
for (int x = 0; x < hw; ++x, d += 4)
|
||||
for (int c = 0; c < 4; ++c) d[c] = (uint8_t)((r0[8 * x + c] + r0[8 * x + 4 + c] + r1[8 * x + c] + r1[8 * x + 4 + c] + 2) >> 2);
|
||||
}
|
||||
f->w = hw; f->h = hh; src = half_.data();
|
||||
f->meta.scale *= 0.5f; f->meta.psx *= 0.5f; f->meta.psy *= 0.5f; // (window pixels per world pixel, the player on the window)
|
||||
j.busy->store(false); // (the window's buffer is free as soon as it is copied)
|
||||
}
|
||||
const size_t rawBytes = (size_t)f->w * f->h * 4;
|
||||
f->comp.resize(rawBytes + BLOSC2_MAX_OVERHEAD);
|
||||
int cs;
|
||||
// Zstd level 1: about 0.2 MB for a 1280x720 frame and 2 ms on this thread (LZ4 level 1: 0.38 MB and 1.3 ms), so the 1200 frames of the ring take about 250 MB
|
||||
{ std::lock_guard<std::mutex> bl(bloscMutex()); blosc1_set_compressor("zstd"); cs = blosc2_compress(1, BLOSC_SHUFFLE, 4, src, (int32_t)rawBytes, f->comp.data(), (int32_t)f->comp.size()); }
|
||||
j.busy->store(false); // the buffer can be written again
|
||||
if (cs <= 0) continue;
|
||||
f->comp.resize((size_t)cs);
|
||||
f->comp.shrink_to_fit(); // (resize keeps the raw-size allocation)
|
||||
std::lock_guard<std::mutex> l(ringMu_);
|
||||
ringBytes_ += f->comp.size();
|
||||
ring_.push_back(std::move(f));
|
||||
// at most max_ frames and budget_ bytes (NG_CLIP_MB, default 1536 MB): the oldest go
|
||||
while (ring_.size() > 1 && (ring_.size() > max_ || ringBytes_ > budget_)) { ringBytes_ -= ring_.front()->comp.size(); ring_.pop_front(); }
|
||||
}
|
||||
}
|
||||
|
||||
void write_all(const std::vector<std::shared_ptr<Frame>>& frames, const std::string& dir) {
|
||||
if (frames.empty()) return;
|
||||
namespace fs = std::filesystem;
|
||||
auto to_rgb = [&](const Frame& f, std::vector<uint8_t>& rgb) {
|
||||
std::vector<uint8_t> bgra((size_t)f.w * f.h * 4);
|
||||
{ std::lock_guard<std::mutex> bl(bloscMutex()); blosc2_decompress(f.comp.data(), (int32_t)f.comp.size(), bgra.data(), (int32_t)bgra.size()); }
|
||||
rgb.resize((size_t)f.w * f.h * 3);
|
||||
for (size_t i = 0; i < (size_t)f.w * f.h; ++i) { rgb[i * 3] = bgra[i * 4 + 2]; rgb[i * 3 + 1] = bgra[i * 4 + 1]; rgb[i * 3 + 2] = bgra[i * 4]; }
|
||||
};
|
||||
// info
|
||||
{
|
||||
FILE* fi = nullptr;
|
||||
fopen_s(&fi, (fs::path(dir) / "info.txt").string().c_str(), "wb");
|
||||
if (fi) {
|
||||
std::fprintf(fi, "frames: %zu (file index, game frame, ms since the first, view x y, player x y)\r\n", frames.size());
|
||||
for (size_t k = 0; k < frames.size(); ++k) std::fprintf(fi, "%04zu frame %d %.1f ms view %d %d player %.1f %.1f\r\n", k, frames[k]->meta.frame, frames[k]->meta.time_ms - frames[0]->meta.time_ms, frames[k]->meta.view_x, frames[k]->meta.view_y, frames[k]->meta.px, frames[k]->meta.py);
|
||||
std::fclose(fi);
|
||||
}
|
||||
}
|
||||
const double span = (frames.back()->meta.time_ms - frames.front()->meta.time_ms) / 1000.0;
|
||||
const double fps = span > 0.01 ? (double)(frames.size() - 1) / span : 60.0;
|
||||
const int step = std::max(1, (int)std::lround(fps / 50.0)); // a GIF frame lasts at least 2 cs
|
||||
// the GIF (half size) and the PNG frames
|
||||
const int gw = frames[0]->w / 2, gh = frames[0]->h / 2;
|
||||
std::vector<std::vector<uint8_t>> gifFrames;
|
||||
std::vector<uint8_t> rgb;
|
||||
std::vector<std::vector<uint8_t>> sample;
|
||||
auto half = [&](const std::vector<uint8_t>& src, int w, int h, std::vector<uint8_t>& dst) {
|
||||
dst.resize((size_t)(w / 2) * (h / 2) * 3);
|
||||
for (int y = 0; y < h / 2; ++y) for (int x = 0; x < w / 2; ++x) for (int c = 0; c < 3; ++c)
|
||||
dst[((size_t)y * (w / 2) + x) * 3 + c] = (uint8_t)((src[((size_t)(2 * y) * w + 2 * x) * 3 + c] + src[((size_t)(2 * y) * w + 2 * x + 1) * 3 + c] + src[((size_t)(2 * y + 1) * w + 2 * x) * 3 + c] + src[((size_t)(2 * y + 1) * w + 2 * x + 1) * 3 + c]) / 4);
|
||||
};
|
||||
std::vector<std::vector<uint8_t>> sheetTiles;
|
||||
const size_t sheetCount = 30;
|
||||
for (size_t k = 0; k < frames.size(); ++k) {
|
||||
const Frame& f = *frames[k];
|
||||
to_rgb(f, rgb);
|
||||
char fn[32];
|
||||
std::snprintf(fn, sizeof fn, "f%04zu.png", k);
|
||||
clipimpl::write_png((fs::path(dir) / fn).string(), rgb.data(), f.w, f.h);
|
||||
if (f.w / 2 == gw && f.h / 2 == gh && k % (size_t)step == 0) { std::vector<uint8_t> hf; half(rgb, f.w, f.h, hf); gifFrames.push_back(std::move(hf)); }
|
||||
if (k % std::max<size_t>(1, frames.size() / sheetCount) == 0 && sheetTiles.size() < sheetCount) { std::vector<uint8_t> hf, qf; half(rgb, f.w, f.h, hf); half(hf, f.w / 2, f.h / 2, qf); sheetTiles.push_back(std::move(qf)); }
|
||||
}
|
||||
// contact sheet: 6 columns of quarter-size frames
|
||||
if (!sheetTiles.empty()) {
|
||||
const int tw = frames[0]->w / 4, th = frames[0]->h / 4, cols = 6, rows = (int)((sheetTiles.size() + cols - 1) / cols);
|
||||
std::vector<uint8_t> sheet((size_t)cols * tw * rows * th * 3, 0);
|
||||
for (size_t t = 0; t < sheetTiles.size(); ++t) {
|
||||
const int cx = (int)(t % cols), cy = (int)(t / cols);
|
||||
for (int y = 0; y < th; ++y) std::memcpy(&sheet[((size_t)(cy * th + y) * cols * tw + (size_t)cx * tw) * 3], &sheetTiles[t][(size_t)y * tw * 3], (size_t)tw * 3);
|
||||
}
|
||||
clipimpl::write_png((fs::path(dir) / "sheet.png").string(), sheet.data(), cols * tw, rows * th);
|
||||
}
|
||||
// GIF
|
||||
if (!gifFrames.empty()) {
|
||||
std::vector<const uint8_t*> ptrs;
|
||||
for (size_t k = 0; k < gifFrames.size(); k += std::max<size_t>(1, gifFrames.size() / 8)) ptrs.push_back(gifFrames[k].data());
|
||||
const clipimpl::Palette pal = clipimpl::make_palette(ptrs, gw, gh);
|
||||
clipimpl::GifWriter gwr;
|
||||
if (gwr.open((fs::path(dir) / "clip.gif").string(), gw, gh, &pal)) {
|
||||
for (const auto& gf : gifFrames) gwr.add(gf.data(), 2); // 2 cs
|
||||
gwr.close();
|
||||
}
|
||||
}
|
||||
std::printf("clip written: %s (%zu frames)%c", dir.c_str(), frames.size(), 10);
|
||||
}
|
||||
|
||||
size_t max_;
|
||||
std::mutex mu_, ringMu_;
|
||||
std::condition_variable cv_;
|
||||
std::deque<Job> queue_;
|
||||
std::vector<uint8_t> half_; // the worker's half-size copy
|
||||
size_t ringBytes_ = 0;
|
||||
size_t budget_ = (std::getenv("NG_CLIP_MB") ? (size_t)std::max(64, std::atoi(std::getenv("NG_CLIP_MB"))) : (size_t)1536) << 20;
|
||||
std::deque<std::shared_ptr<Frame>> ring_;
|
||||
std::thread worker_;
|
||||
std::vector<std::thread> writers_;
|
||||
bool stop_ = false;
|
||||
std::atomic<size_t> dropped_{0};
|
||||
};
|
||||
|
||||
} // namespace ng
|
||||
@@ -0,0 +1,420 @@
|
||||
#pragma once
|
||||
// The replay clip editor (F11), after the game's own: the frames kept by the replay recorder (clip.h) are shown over the dimmed, paused game. A strip of thumbnails
|
||||
// covers the whole recording; left click on it sets the start of the clip, right click the end, and the preview box plays the clip in a loop (or shows the hovered
|
||||
// frame). Enter writes the clip as clips/gif_<time>.gif on a worker thread, either the whole window or a box of 240x240 ... 480x320 world pixels that follows the
|
||||
// player (the player position of every frame is in its ClipMeta), at 100% / 50% / 33% / 25% of the recorded pixels and every 1st, 2nd or 3rd frame.
|
||||
// The host routes the keys and the mouse buttons here while it is open (see Input), pauses the game and stops recording, and draws it over the HUD.
|
||||
#include <atomic>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <memory>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
#include "app/gif_meta.h"
|
||||
#include "app/clip.h"
|
||||
#include "app/hud.h"
|
||||
|
||||
namespace ng {
|
||||
|
||||
class GifEditor {
|
||||
public:
|
||||
struct Input {
|
||||
int mx = -1, my = -1;
|
||||
int ldown = 0, rdown = 0; // presses of the left / right button since the last frame
|
||||
std::vector<int> keys; // virtual key codes pressed since the last frame
|
||||
};
|
||||
enum Result { kNone = 0, kClose = 1, kOpenDir = 2 };
|
||||
static constexpr int kDither = 4; // the span of the ordered dither in 0..255 units: light, so that the flat areas of the pixel art stay flat
|
||||
|
||||
~GifEditor() { stop_thumbs(); if (saver_.joinable()) saver_.join(); }
|
||||
|
||||
bool is_open() const { return open_; }
|
||||
|
||||
void open(std::vector<std::shared_ptr<const ClipFrame>> frames) {
|
||||
stop_thumbs();
|
||||
frames_ = std::move(frames);
|
||||
open_ = !frames_.empty();
|
||||
if (!open_) return;
|
||||
const int n = (int)frames_.size();
|
||||
times_.resize((size_t)n);
|
||||
for (int i = 0; i < n; ++i) times_[(size_t)i] = frames_[(size_t)i]->meta.time_ms;
|
||||
in_ = 0; out_ = n - 1; ph_ = 0; playing_ = true; acc_ = 0; last_ = std::chrono::steady_clock::now();
|
||||
cacheIdx_ = -1; hits_.clear(); stripW_ = 0;
|
||||
thumbs_.assign((size_t)n, std::vector<uint32_t>());
|
||||
ready_.reset(new std::atomic<bool>[(size_t)n]);
|
||||
for (int i = 0; i < n; ++i) ready_[(size_t)i] = false;
|
||||
thumbStop_ = false;
|
||||
thumbT_ = std::thread([this] { make_thumbs(); });
|
||||
}
|
||||
void close() { stop_thumbs(); open_ = false; frames_.clear(); thumbs_.clear(); cache_.clear(); cacheIdx_ = -1; }
|
||||
|
||||
// Settings (the test hook sets them directly).
|
||||
void set_range(int a, int b) { const int n = (int)frames_.size(); in_ = std::clamp(a, 0, n - 1); out_ = std::clamp(b, in_, n - 1); ph_ = in_; acc_ = 0; }
|
||||
void set_size(int k) { size_ = std::clamp(k, 0, kSizes - 1); }
|
||||
void set_scale(int k) { scale_ = std::clamp(k, 0, kScales - 1); }
|
||||
void set_step(int k) { step_ = std::clamp(k, 1, 3); }
|
||||
bool saving() const { return saving_.load(); }
|
||||
void wait_save() { if (saver_.joinable()) saver_.join(); }
|
||||
void set_meta(const GifMeta& m) { meta_ = m; } // the build and the wands written into the next saved GIF (docs/GIF_METADATA.md)
|
||||
void set_test_palette(bool global, int dither) { globalPal_ = global; dither_ = dither; } // (test: the old single palette, the span of the dither)
|
||||
|
||||
// Starts writing the clip to <dir>/gif_<time>.gif on the worker thread (nothing happens while a save is running).
|
||||
void save(const std::string& dir) {
|
||||
if (!open_ || saving_.load()) return;
|
||||
if (saver_.joinable()) saver_.join();
|
||||
std::vector<clipimpl::GifPick> picks = clipimpl::gif_pick(times_, in_, out_, step_);
|
||||
if (picks.empty()) return;
|
||||
int ow = 0, oh = 0;
|
||||
output_size(ow, oh);
|
||||
std::vector<std::shared_ptr<const ClipFrame>> sel;
|
||||
for (const auto& p : picks) sel.push_back(frames_[(size_t)p.index]);
|
||||
std::error_code ec;
|
||||
std::filesystem::create_directories(dir, ec);
|
||||
const std::time_t t = std::time(nullptr);
|
||||
std::tm tmv{};
|
||||
localtime_s(&tmv, &t);
|
||||
char name[64];
|
||||
std::snprintf(name, sizeof name, "gif_%04d%02d%02d_%02d%02d%02d", tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, tmv.tm_hour, tmv.tm_min, tmv.tm_sec);
|
||||
std::string path = (std::filesystem::path(dir) / (std::string(name) + ".gif")).string();
|
||||
for (int k = 2; std::filesystem::exists(path, ec); ++k) path = (std::filesystem::path(dir) / (std::string(name) + "_" + std::to_string(k) + ".gif")).string();
|
||||
saveDone_ = 0; saveTotal_ = (int)picks.size(); saving_ = true;
|
||||
const int size = size_;
|
||||
saver_ = std::thread([this, sel = std::move(sel), picks = std::move(picks), ow, oh, size, path] { write_gif(sel, picks, ow, oh, size, path); });
|
||||
}
|
||||
|
||||
Result update(const Input& in) {
|
||||
if (!open_) return kNone;
|
||||
const int n = (int)frames_.size();
|
||||
Result res = kNone;
|
||||
for (int k : in.keys) {
|
||||
if (k == VK_ESCAPE) res = kClose;
|
||||
else if (k == VK_RETURN) save("clips");
|
||||
else if (k == VK_SPACE) { playing_ = !playing_; acc_ = 0; }
|
||||
else if (k == VK_LEFT) { playing_ = false; ph_ = std::max(0, ph_ - 1); }
|
||||
else if (k == VK_RIGHT) { playing_ = false; ph_ = std::min(n - 1, ph_ + 1); }
|
||||
else if (k == VK_HOME) { playing_ = false; ph_ = in_; }
|
||||
else if (k == VK_END) { playing_ = false; ph_ = out_; }
|
||||
else if (k == 'I') { in_ = ph_; if (out_ < in_) out_ = n - 1; }
|
||||
else if (k == 'O') { out_ = ph_; if (out_ < in_) in_ = 0; }
|
||||
}
|
||||
hover_ = strip_frame(in.mx, in.my);
|
||||
if (hover_ >= 0) {
|
||||
if (in.ldown) { in_ = hover_; if (out_ < in_) out_ = n - 1; ph_ = in_; acc_ = 0; }
|
||||
if (in.rdown) { out_ = hover_; if (out_ < in_) in_ = 0; ph_ = in_; acc_ = 0; }
|
||||
} else if (in.ldown) {
|
||||
for (const Hit& h : hits_) {
|
||||
if (in.mx < h.x || in.my < h.y || in.mx >= h.x + h.w || in.my >= h.y + h.h) continue;
|
||||
if (h.id == kHitSave) save("clips");
|
||||
else if (h.id == kHitDir) res = kOpenDir;
|
||||
else if (h.id == kHitReturn) res = kClose;
|
||||
else if (h.id == kHitScale) scale_ = (scale_ + 1) % kScales;
|
||||
else if (h.id >= kHitSize && h.id < kHitSize + kSizes) size_ = h.id - kHitSize;
|
||||
else if (h.id >= kHitStep && h.id < kHitStep + 3) step_ = h.id - kHitStep + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// playback of [in, out] at the recorded speed
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
acc_ += std::chrono::duration<double, std::milli>(now - last_).count();
|
||||
last_ = now;
|
||||
if (ph_ < in_ || ph_ > out_) { if (playing_) ph_ = in_; }
|
||||
if (!playing_ || in_ == out_) acc_ = 0;
|
||||
for (int guard = 0; playing_ && guard < 8; ++guard) {
|
||||
const double dt = ph_ < out_ ? times_[(size_t)ph_ + 1] - times_[(size_t)ph_] : 1000.0 / 60.0;
|
||||
if (acc_ < dt) break;
|
||||
acc_ -= dt;
|
||||
ph_ = ph_ < out_ ? ph_ + 1 : in_;
|
||||
}
|
||||
if (acc_ > 250) acc_ = 0;
|
||||
mx_ = in.mx; my_ = in.my;
|
||||
return res;
|
||||
}
|
||||
|
||||
void draw(hud::Canvas& cv) {
|
||||
if (!open_) return;
|
||||
cv.mark_all(); // (it dims the whole window and writes pixels directly)
|
||||
using hud::rgba;
|
||||
const uint32_t grey = rgba(170, 170, 170), white = rgba(255, 255, 255), yellow = rgba(255, 220, 90), dimText = rgba(120, 120, 120);
|
||||
dim(cv);
|
||||
hits_.clear();
|
||||
const int ts = std::max(1, cv.h / 360);
|
||||
const int lh = ((cv.font && cv.font->ok()) ? cv.font->line_height : 7) * ts + 3 * ts;
|
||||
const int x0 = 8 * ts;
|
||||
int y = 6 * ts;
|
||||
auto over = [&](int x, int yy, int w, int h) { return mx_ >= x && my_ >= yy && mx_ < x + w && my_ < yy + h; };
|
||||
auto item = [&](int x, int yy, const std::string& s, int id, uint32_t c, uint32_t hc) { // clickable text, returns its width
|
||||
const int w = cv.text_width(s, ts);
|
||||
const bool o = over(x, yy, w, lh);
|
||||
cv.text(x, yy, s, o ? hc : c, ts);
|
||||
hits_.push_back({x, yy, w, lh, id});
|
||||
return w;
|
||||
};
|
||||
const int n = (int)frames_.size();
|
||||
cv.text(x0, y, "REPLAY CLIP EDITOR - Right/Left click on frames to set clip start and end points.", grey, ts);
|
||||
y += lh;
|
||||
{
|
||||
const std::string s = saving_.load() ? "Saving gif... " + std::to_string(saveDone_.load()) + "/" + std::to_string(saveTotal_.load()) : std::string("Save as Gif [ENTER]");
|
||||
const int w = item(x0, y, s, kHitSave, white, yellow);
|
||||
std::lock_guard<std::mutex> l(statusMu_);
|
||||
if (!status_.empty()) cv.text(x0 + w + 24 * ts, y, status_, grey, ts);
|
||||
}
|
||||
y += lh;
|
||||
item(x0, y, "Open gif directory", kHitDir, white, yellow);
|
||||
y += lh;
|
||||
{
|
||||
int x = x0;
|
||||
cv.text(x, y, "Size:", grey, ts);
|
||||
x += cv.text_width("Size: ", ts) + 6 * ts;
|
||||
for (int k = 0; k < kSizes; ++k) {
|
||||
const std::string s = k == 0 ? std::string("Full") : std::to_string(kSizeW[k]) + "x" + std::to_string(kSizeH[k]);
|
||||
x += item(x, y, s, kHitSize + k, k == size_ ? yellow : grey, k == size_ ? yellow : white) + 12 * ts;
|
||||
}
|
||||
}
|
||||
y += lh;
|
||||
item(x0, y, std::string("Output scale: ") + kScaleName[scale_], kHitScale, white, yellow);
|
||||
y += lh;
|
||||
{
|
||||
int x = x0;
|
||||
cv.text(x, y, "Frame step:", grey, ts);
|
||||
x += cv.text_width("Frame step: ", ts) + 6 * ts;
|
||||
for (int k = 1; k <= 3; ++k) x += item(x, y, std::to_string(k), kHitStep + k - 1, k == step_ ? yellow : grey, k == step_ ? yellow : white) + 12 * ts;
|
||||
}
|
||||
y += lh;
|
||||
{
|
||||
const std::vector<clipimpl::GifPick> picks = clipimpl::gif_pick(times_, in_, out_, step_);
|
||||
int cs = 0;
|
||||
for (const auto& p : picks) cs += p.delay_cs;
|
||||
int ow = 0, oh = 0;
|
||||
output_size(ow, oh);
|
||||
std::string s = "Output: " + std::to_string(picks.size()) + " frames - " + hud::fmt("%.1f", cs / 100.0) + "s - " + std::to_string(ow) + "x" + std::to_string(oh) + " px";
|
||||
if (ratio_ > 0) s += " - about " + hud::fmt("%.1f", ratio_ * ow * oh * (double)picks.size() / 1048576.0) + " MB";
|
||||
cv.text(x0, y, s, grey, ts);
|
||||
}
|
||||
y += lh;
|
||||
const int shown = hover_ >= 0 ? hover_ : ph_;
|
||||
cv.text(x0, y, "Left click - start clip on hovered frame. Right click - end clip on hovered frame. Frame = " + std::to_string(shown), grey, ts);
|
||||
y += lh + 2 * ts;
|
||||
// the strip of thumbnails
|
||||
stripX_ = x0; stripY_ = y; stripW_ = cv.w - 2 * x0; stripH_ = 15 * ts;
|
||||
{
|
||||
const int cellsMax = std::max(1, stripW_ / std::max(1, stripH_ * 16 / 9));
|
||||
const int cells = std::min(n, cellsMax);
|
||||
for (int c = 0; c < cells; ++c) {
|
||||
const int cx0 = stripX_ + c * stripW_ / cells, cx1 = stripX_ + (c + 1) * stripW_ / cells;
|
||||
const int g0 = c * n / cells, g1 = std::max(g0 + 1, (c + 1) * n / cells);
|
||||
int tf = -1;
|
||||
for (int f = g0; f < g1 && tf < 0; ++f) if (ready_[(size_t)f].load()) tf = f;
|
||||
const int iw = cx1 - cx0 - 2, ih = stripH_ - 2;
|
||||
if (tf >= 0) {
|
||||
const std::vector<uint32_t>& t = thumbs_[(size_t)tf];
|
||||
for (int j = 0; j < ih; ++j) {
|
||||
const int py = stripY_ + 1 + j;
|
||||
if (py < 0 || py >= cv.h) continue;
|
||||
const int sy = j * kThumbH / ih;
|
||||
for (int i = 0; i < iw; ++i) { const int px = cx0 + 1 + i; if (px >= 0 && px < cv.w) cv.px[(size_t)py * cv.w + px] = t[(size_t)sy * kThumbW + i * kThumbW / iw]; }
|
||||
}
|
||||
} else cv.rect(cx0 + 1, stripY_ + 1, iw, ih, rgba(30, 30, 30));
|
||||
cv.frame(cx0, stripY_, cx1 - cx0, stripH_, rgba(90, 90, 90));
|
||||
}
|
||||
for (int px = stripX_; px < stripX_ + stripW_; ++px) { // the frames outside the clip are darkened
|
||||
const int f = (px - stripX_) * n / stripW_;
|
||||
if (f >= in_ && f <= out_) continue;
|
||||
for (int py = stripY_; py < stripY_ + stripH_; ++py) if (px >= 0 && px < cv.w && py >= 0 && py < cv.h) { uint32_t& d = cv.px[(size_t)py * cv.w + px]; d = (d & 0xFF000000u) | ((d >> 2) & 0x3F3F3Fu); }
|
||||
}
|
||||
auto mark = [&](int f, uint32_t c, int w) { const int px = stripX_ + (int)((f + 0.5) * stripW_ / n); cv.rect(px - w / 2, stripY_ - 2 * ts, w, stripH_ + 4 * ts, c); };
|
||||
mark(in_, rgba(90, 220, 90), 2 * ts);
|
||||
mark(out_, rgba(230, 80, 70), 2 * ts);
|
||||
if (hover_ < 0) mark(ph_, white, ts); // (while the preview shows the hovered frame, only its line is drawn)
|
||||
if (hover_ >= 0) mark(hover_, yellow, ts);
|
||||
}
|
||||
y += stripH_ + 6 * ts;
|
||||
// the preview box: what the GIF shows
|
||||
{
|
||||
const int bottom = cv.h - lh - 6 * ts;
|
||||
int bw = cv.w * 2 / 3, bh = bw * 9 / 16;
|
||||
if (y + bh > bottom) { bh = std::max(16, bottom - y); bw = bh * 16 / 9; }
|
||||
cv.rect(x0, y, bw, bh, rgba(12, 12, 12));
|
||||
const ClipFrame* f = frame_bgra(shown);
|
||||
if (f) {
|
||||
int rx, ry, rw, rh;
|
||||
source_rect(*f, rx, ry, rw, rh);
|
||||
const double s = std::min((double)(bw - 2) / rw, (double)(bh - 2) / rh);
|
||||
const int dw = std::max(1, (int)(rw * s)), dh = std::max(1, (int)(rh * s));
|
||||
const int dx = x0 + (bw - dw) / 2, dy = y + (bh - dh) / 2;
|
||||
const uint32_t* src = reinterpret_cast<const uint32_t*>(cache_.data());
|
||||
xmap_.resize((size_t)dw);
|
||||
for (int i = 0; i < dw; ++i) xmap_[(size_t)i] = rx + std::min(rw - 1, (int)(i / s));
|
||||
for (int j = 0; j < dh; ++j) {
|
||||
const int py = dy + j;
|
||||
if (py < 0 || py >= cv.h) continue;
|
||||
const uint32_t* row = src + (size_t)(ry + std::min(rh - 1, (int)(j / s))) * f->w;
|
||||
uint32_t* dst = cv.px + (size_t)py * cv.w;
|
||||
for (int i = 0; i < dw; ++i) { const int px = dx + i; if (px >= 0 && px < cv.w) dst[px] = 0xFF000000u | (row[xmap_[(size_t)i]] & 0xFFFFFFu); } // (BGRA in memory is ARGB as a number)
|
||||
}
|
||||
}
|
||||
cv.frame(x0 - 1, y - 1, bw + 2, bh + 2, rgba(200, 200, 200));
|
||||
}
|
||||
{
|
||||
const int w = item(x0, cv.h - lh - 2 * ts, "Return to game [ESC]", kHitReturn, white, yellow);
|
||||
const std::string keys = std::string(playing_ ? "Space pause" : "Space play") + " Left/Right step I/O start/end at this frame Home/End go to start/end";
|
||||
cv.text(x0 + w + 30 * ts, cv.h - lh - 2 * ts, keys, dimText, ts);
|
||||
}
|
||||
if (mx_ >= 0) { // the cursor (the HUD's crosshair is under the editor)
|
||||
const uint32_t c = rgba(255, 255, 255, 230);
|
||||
cv.rect(mx_ - 8, my_ - 1, 6, 2, c); cv.rect(mx_ + 3, my_ - 1, 6, 2, c);
|
||||
cv.rect(mx_ - 1, my_ - 8, 2, 6, c); cv.rect(mx_ - 1, my_ + 3, 2, 6, c);
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
static constexpr int kSizes = 6, kScales = 4, kThumbW = 64, kThumbH = 36;
|
||||
static constexpr int kSizeW[kSizes] = {0, 240, 320, 320, 480, 480}, kSizeH[kSizes] = {0, 240, 180, 320, 270, 320}; // world pixels (cells)
|
||||
static constexpr double kScalePct[kScales] = {1.0, 0.5, 1.0 / 3.0, 0.25};
|
||||
static constexpr const char* kScaleName[kScales] = {"100%", "50%", "33%", "25%"};
|
||||
enum { kHitSave = 1, kHitDir, kHitReturn, kHitScale, kHitSize = 10, kHitStep = 20 };
|
||||
struct Hit { int x, y, w, h, id; };
|
||||
|
||||
// The part of a recorded frame that goes into the GIF: the whole frame, or the chosen box of world pixels centred on the player (inside the frame).
|
||||
static void source_rect(const ClipFrame& f, int size, int& x, int& y, int& w, int& h) {
|
||||
if (size <= 0) { x = 0; y = 0; w = f.w; h = f.h; return; }
|
||||
const float s = f.meta.scale > 0 ? f.meta.scale : 1.0f;
|
||||
w = std::clamp((int)std::lround(kSizeW[size] * s), 1, f.w);
|
||||
h = std::clamp((int)std::lround(kSizeH[size] * s), 1, f.h);
|
||||
x = std::clamp((int)std::lround(f.meta.psx - w / 2.0), 0, f.w - w);
|
||||
y = std::clamp((int)std::lround(f.meta.psy - h / 2.0), 0, f.h - h);
|
||||
}
|
||||
void source_rect(const ClipFrame& f, int& x, int& y, int& w, int& h) const { source_rect(f, size_, x, y, w, h); }
|
||||
void output_size(int& ow, int& oh) const {
|
||||
int x, y, w = 1, h = 1;
|
||||
if (!frames_.empty()) source_rect(*frames_[(size_t)in_], x, y, w, h);
|
||||
ow = std::max(1, (int)std::lround(w * kScalePct[scale_]));
|
||||
oh = std::max(1, (int)std::lround(h * kScalePct[scale_]));
|
||||
}
|
||||
|
||||
// The frame under the strip at (mx, my), -1 if none.
|
||||
int strip_frame(int mx, int my) const {
|
||||
if (stripW_ <= 0 || frames_.empty() || mx < stripX_ || mx >= stripX_ + stripW_ || my < stripY_ || my >= stripY_ + stripH_) return -1;
|
||||
return std::clamp((mx - stripX_) * (int)frames_.size() / stripW_, 0, (int)frames_.size() - 1);
|
||||
}
|
||||
|
||||
const ClipFrame* frame_bgra(int i) {
|
||||
if (i < 0 || i >= (int)frames_.size()) return nullptr;
|
||||
if (cacheIdx_ != i) { if (!clipimpl::decode_bgra(*frames_[(size_t)i], cache_)) return nullptr; cacheIdx_ = i; }
|
||||
return frames_[(size_t)i].get();
|
||||
}
|
||||
|
||||
// Darkens what is under the editor (the overlay blends over the frozen game picture).
|
||||
static void dim(hud::Canvas& cv) {
|
||||
const uint32_t a = 160;
|
||||
for (size_t i = 0, n = (size_t)cv.w * cv.h; i < n; ++i) {
|
||||
uint32_t& d = cv.px[i];
|
||||
const uint32_t da = d >> 24;
|
||||
if (da == 0) { d = a << 24; continue; }
|
||||
const uint32_t oa = a + da * (255 - a) / 255;
|
||||
auto ch = [&](int sh) { return ((d >> sh & 255) * da * (255 - a) / 255) / oa; };
|
||||
d = oa << 24 | ch(16) << 16 | ch(8) << 8 | ch(0);
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnails for the strip, coarse first (every 64th frame, then the ones between) so that the strip fills quickly.
|
||||
void make_thumbs() {
|
||||
const int n = (int)frames_.size();
|
||||
std::vector<uint8_t> bgra, rgb;
|
||||
for (int stride = 64; stride >= 1; stride /= 2)
|
||||
for (int i = 0; i < n; i += stride) {
|
||||
if (thumbStop_.load()) return;
|
||||
if (ready_[(size_t)i].load()) continue;
|
||||
const ClipFrame& f = *frames_[(size_t)i];
|
||||
if (!clipimpl::decode_bgra(f, bgra)) continue;
|
||||
clipimpl::crop_scale_rgb(bgra.data(), f.w, 0, 0, f.w, f.h, kThumbW, kThumbH, rgb);
|
||||
std::vector<uint32_t> t((size_t)kThumbW * kThumbH);
|
||||
for (size_t k = 0; k < t.size(); ++k) t[k] = 0xFF000000u | (uint32_t)rgb[k * 3] << 16 | (uint32_t)rgb[k * 3 + 1] << 8 | rgb[k * 3 + 2];
|
||||
thumbs_[(size_t)i] = std::move(t);
|
||||
ready_[(size_t)i] = true;
|
||||
}
|
||||
}
|
||||
void stop_thumbs() { thumbStop_ = true; if (thumbT_.joinable()) thumbT_.join(); }
|
||||
|
||||
void write_gif(const std::vector<std::shared_ptr<const ClipFrame>>& sel, const std::vector<clipimpl::GifPick>& picks, int ow, int oh, int size, const std::string& path) {
|
||||
std::vector<uint8_t> bgra, rgb;
|
||||
auto out_rgb = [&](const ClipFrame& f, std::vector<uint8_t>& dst) {
|
||||
if (!clipimpl::decode_bgra(f, bgra)) { dst.assign((size_t)ow * oh * 3, 0); return; }
|
||||
int x, y, w, h;
|
||||
source_rect(f, size, x, y, w, h);
|
||||
clipimpl::crop_scale_rgb(bgra.data(), f.w, x, y, w, h, ow, oh, dst);
|
||||
};
|
||||
// every frame has its own palette (a local colour table, clipimpl::make_palette_frame) and a light ordered dither; the test switch globalPal_ makes the
|
||||
// old single palette of 8 frames spread over the clip instead (for comparisons)
|
||||
clipimpl::Palette global;
|
||||
if (globalPal_) {
|
||||
std::vector<std::vector<uint8_t>> sample;
|
||||
for (size_t k = 0; k < sel.size(); k += std::max<size_t>(1, sel.size() / 8)) { sample.emplace_back(); out_rgb(*sel[k], sample.back()); }
|
||||
std::vector<const uint8_t*> ptrs;
|
||||
for (const auto& s : sample) ptrs.push_back(s.data());
|
||||
global = clipimpl::make_palette(ptrs, ow, oh);
|
||||
}
|
||||
clipimpl::GifWriter gw;
|
||||
size_t bytes = 0;
|
||||
if (gw.open(path, ow, oh, globalPal_ ? &global : nullptr)) {
|
||||
for (size_t k = 0; k < sel.size(); ++k) {
|
||||
out_rgb(*sel[k], rgb);
|
||||
if (globalPal_) gw.add(rgb.data(), picks[k].delay_cs);
|
||||
else { const clipimpl::Palette pal = clipimpl::make_palette_frame(rgb.data(), (size_t)ow * oh); gw.add(rgb.data(), picks[k].delay_cs, &pal, dither_); }
|
||||
saveDone_ = (int)k + 1;
|
||||
}
|
||||
bytes = gw.close();
|
||||
if (bytes) { // the signed metadata (NGPUMETA)
|
||||
GifMeta m = meta_;
|
||||
m.frames = (int)sel.size(); m.width = ow; m.height = oh; m.duration_ms = 0;
|
||||
for (const auto& p : picks) m.duration_ms += p.delay_cs * 10;
|
||||
if (gif_embed_meta(path, m)) { std::error_code ec2; bytes = (size_t)std::filesystem::file_size(path, ec2); }
|
||||
else std::printf("gif metadata: could not write %s%c", path.c_str(), 10);
|
||||
}
|
||||
}
|
||||
std::string msg;
|
||||
if (bytes) {
|
||||
ratio_ = (double)bytes / ((double)ow * oh * sel.size());
|
||||
msg = "saved " + path + " (" + hud::fmt("%.2f", bytes / 1048576.0) + " MB)";
|
||||
std::printf("gif written: %s (%zu frames, %dx%d, %zu bytes)%c", path.c_str(), sel.size(), ow, oh, bytes, 10);
|
||||
} else {
|
||||
msg = "could not write " + path;
|
||||
std::printf("%s%c", msg.c_str(), 10);
|
||||
}
|
||||
{ std::lock_guard<std::mutex> l(statusMu_); status_ = msg; }
|
||||
saving_ = false;
|
||||
}
|
||||
|
||||
bool open_ = false;
|
||||
GifMeta meta_;
|
||||
std::vector<std::shared_ptr<const ClipFrame>> frames_;
|
||||
std::vector<double> times_;
|
||||
int in_ = 0, out_ = 0, ph_ = 0, hover_ = -1, mx_ = -1, my_ = -1;
|
||||
int size_ = 0, scale_ = 1, step_ = 1;
|
||||
bool globalPal_ = false;
|
||||
int dither_ = kDither;
|
||||
bool playing_ = true;
|
||||
double acc_ = 0;
|
||||
std::chrono::steady_clock::time_point last_;
|
||||
std::vector<uint8_t> cache_;
|
||||
int cacheIdx_ = -1;
|
||||
std::vector<int> xmap_;
|
||||
std::vector<Hit> hits_;
|
||||
int stripX_ = 0, stripY_ = 0, stripW_ = 0, stripH_ = 0;
|
||||
std::vector<std::vector<uint32_t>> thumbs_;
|
||||
std::unique_ptr<std::atomic<bool>[]> ready_;
|
||||
std::thread thumbT_;
|
||||
std::atomic<bool> thumbStop_{false};
|
||||
std::thread saver_;
|
||||
std::atomic<bool> saving_{false};
|
||||
std::atomic<int> saveDone_{0}, saveTotal_{0};
|
||||
std::atomic<double> ratio_{0};
|
||||
std::mutex statusMu_;
|
||||
std::string status_;
|
||||
};
|
||||
|
||||
} // namespace ng
|
||||
@@ -0,0 +1,192 @@
|
||||
#pragma once
|
||||
// The metadata of the GIFs of the clip editor (docs/GIF_METADATA.md): an application extension "NGPUMETA" "1.0" with a JSON object (the build, the seed,
|
||||
// the player's wands) and its signature, HMAC-SHA256 over the canonical JSON and the SHA-256 of the GIF without that block. The upload site checks it.
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <ctime>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ng {
|
||||
|
||||
// The shared secret of the signature (the site's NGPU_GIF_KEY). It is inside the executable: the signature shows that a file came out of noita_gpu
|
||||
// unchanged, not who made it.
|
||||
inline const char* kGifMetaKey = "aDILI6RDUCzKjdX-ns7qHHddiyWH5iW0Fq42ktl7fPvS-0YL";
|
||||
|
||||
namespace gifmeta {
|
||||
|
||||
struct Sha256 {
|
||||
uint32_t h[8] = {0x6a09e667u, 0xbb67ae85u, 0x3c6ef372u, 0xa54ff53au, 0x510e527fu, 0x9b05688cu, 0x1f83d9abu, 0x5be0cd19u};
|
||||
uint8_t buf[64] = {};
|
||||
size_t n = 0;
|
||||
uint64_t total = 0;
|
||||
static uint32_t rotr(uint32_t x, int k) { return (x >> k) | (x << (32 - k)); }
|
||||
void block(const uint8_t* p) {
|
||||
static const uint32_t K[64] = {
|
||||
0x428a2f98u, 0x71374491u, 0xb5c0fbcfu, 0xe9b5dba5u, 0x3956c25bu, 0x59f111f1u, 0x923f82a4u, 0xab1c5ed5u, 0xd807aa98u, 0x12835b01u, 0x243185beu, 0x550c7dc3u,
|
||||
0x72be5d74u, 0x80deb1feu, 0x9bdc06a7u, 0xc19bf174u, 0xe49b69c1u, 0xefbe4786u, 0x0fc19dc6u, 0x240ca1ccu, 0x2de92c6fu, 0x4a7484aau, 0x5cb0a9dcu, 0x76f988dau,
|
||||
0x983e5152u, 0xa831c66du, 0xb00327c8u, 0xbf597fc7u, 0xc6e00bf3u, 0xd5a79147u, 0x06ca6351u, 0x14292967u, 0x27b70a85u, 0x2e1b2138u, 0x4d2c6dfcu, 0x53380d13u,
|
||||
0x650a7354u, 0x766a0abbu, 0x81c2c92eu, 0x92722c85u, 0xa2bfe8a1u, 0xa81a664bu, 0xc24b8b70u, 0xc76c51a3u, 0xd192e819u, 0xd6990624u, 0xf40e3585u, 0x106aa070u,
|
||||
0x19a4c116u, 0x1e376c08u, 0x2748774cu, 0x34b0bcb5u, 0x391c0cb3u, 0x4ed8aa4au, 0x5b9cca4fu, 0x682e6ff3u, 0x748f82eeu, 0x78a5636fu, 0x84c87814u, 0x8cc70208u,
|
||||
0x90befffau, 0xa4506cebu, 0xbef9a3f7u, 0xc67178f2u};
|
||||
uint32_t w[64];
|
||||
for (int i = 0; i < 16; ++i) w[i] = (uint32_t)p[i * 4] << 24 | (uint32_t)p[i * 4 + 1] << 16 | (uint32_t)p[i * 4 + 2] << 8 | p[i * 4 + 3];
|
||||
for (int i = 16; i < 64; ++i) {
|
||||
const uint32_t s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >> 3), s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >> 10);
|
||||
w[i] = w[i - 16] + s0 + w[i - 7] + s1;
|
||||
}
|
||||
uint32_t a = h[0], b = h[1], c = h[2], d = h[3], e = h[4], f = h[5], g = h[6], hh = h[7];
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
const uint32_t t1 = hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i];
|
||||
const uint32_t t2 = (rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & b) ^ (a & c) ^ (b & c));
|
||||
hh = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2;
|
||||
}
|
||||
h[0] += a; h[1] += b; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
|
||||
}
|
||||
void update(const uint8_t* p, size_t len) {
|
||||
total += len;
|
||||
while (len > 0) {
|
||||
const size_t k = std::min(len, 64 - n);
|
||||
std::memcpy(buf + n, p, k); n += k; p += k; len -= k;
|
||||
if (n == 64) { block(buf); n = 0; }
|
||||
}
|
||||
}
|
||||
void update(const std::string& s) { update((const uint8_t*)s.data(), s.size()); }
|
||||
std::array<uint8_t, 32> finish() {
|
||||
const uint64_t bits = total * 8;
|
||||
const uint8_t one = 0x80, zero = 0;
|
||||
update(&one, 1);
|
||||
while (n != 56) update(&zero, 1);
|
||||
uint8_t len[8];
|
||||
for (int i = 0; i < 8; ++i) len[i] = (uint8_t)(bits >> (56 - 8 * i));
|
||||
update(len, 8);
|
||||
std::array<uint8_t, 32> out{};
|
||||
for (int i = 0; i < 8; ++i) for (int k = 0; k < 4; ++k) out[i * 4 + k] = (uint8_t)(h[i] >> (24 - 8 * k));
|
||||
return out;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::string hex(const uint8_t* p, size_t n) { static const char* d = "0123456789abcdef"; std::string s; for (size_t i = 0; i < n; ++i) { s += d[p[i] >> 4]; s += d[p[i] & 15]; } return s; }
|
||||
|
||||
inline std::string hmac_sha256_hex(const std::string& key, const std::string& msg) {
|
||||
uint8_t k[64] = {};
|
||||
if (key.size() > 64) { Sha256 s; s.update(key); const auto d = s.finish(); std::memcpy(k, d.data(), 32); } else std::memcpy(k, key.data(), key.size());
|
||||
uint8_t ip[64], op[64];
|
||||
for (int i = 0; i < 64; ++i) { ip[i] = k[i] ^ 0x36; op[i] = k[i] ^ 0x5c; }
|
||||
Sha256 in; in.update(ip, 64); in.update(msg); const auto ih = in.finish();
|
||||
Sha256 out; out.update(op, 64); out.update(ih.data(), 32); const auto oh = out.finish();
|
||||
return hex(oh.data(), 32);
|
||||
}
|
||||
|
||||
// JSON as Python's json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=True) writes it (ASCII strings, numbers that round-trip)
|
||||
inline std::string jstr(const std::string& s) {
|
||||
std::string o = "\"";
|
||||
for (unsigned char c : s) {
|
||||
if (c == '"') o += "\\\"";
|
||||
else if (c == '\\') o += "\\\\";
|
||||
else if (c == '\n') o += "\\n";
|
||||
else if (c == '\t') o += "\\t";
|
||||
else if (c == '\r') o += "\\r";
|
||||
else if (c == '\b') o += "\\b";
|
||||
else if (c == '\f') o += "\\f";
|
||||
else if (c < 0x20) { char b[8]; std::snprintf(b, sizeof b, "\\u%04x", c); o += b; }
|
||||
else if (c >= 0x80) o += '?'; // (ASCII only: a byte of a multi-byte character becomes '?')
|
||||
else o += (char)c;
|
||||
}
|
||||
return o + "\"";
|
||||
}
|
||||
inline std::string jnum(double v) { // integers without a point, other numbers with up to 6 decimals and at least one (Python's repr of the parsed value)
|
||||
if (v == (double)(long long)v && v > -1e15 && v < 1e15) return std::to_string((long long)v);
|
||||
char b[64];
|
||||
std::snprintf(b, sizeof b, "%.6f", v);
|
||||
std::string s = b;
|
||||
while (s.size() > 1 && s.back() == '0' && s[s.size() - 2] != '.') s.pop_back();
|
||||
if (s == "-0.0") s = "0.0";
|
||||
return s;
|
||||
}
|
||||
inline std::string jbool(bool b) { return b ? "true" : "false"; }
|
||||
inline std::string jlist(const std::vector<std::string>& v) { std::string o = "["; for (size_t i = 0; i < v.size(); ++i) { if (i) o += ","; o += jstr(v[i]); } return o + "]"; }
|
||||
|
||||
} // namespace gifmeta
|
||||
|
||||
struct GifMetaWand {
|
||||
int slot = 0;
|
||||
std::string name;
|
||||
bool shuffle = false;
|
||||
int spells_per_cast = 1, cast_delay = 0, recharge_time = 0, capacity = 0;
|
||||
double mana_max = 0, mana_charge_speed = 0, spread_degrees = 0, speed_multiplier = 1;
|
||||
std::vector<std::string> always_cast, deck;
|
||||
};
|
||||
|
||||
struct GifMeta {
|
||||
std::string build = "dev", created, mode = "world";
|
||||
long long seed = 0;
|
||||
int frames = 0, width = 0, height = 0, duration_ms = 0, active_wand = -1;
|
||||
std::vector<GifMetaWand> wands;
|
||||
|
||||
std::string canonical() const { // the object without "sig", keys sorted
|
||||
using namespace gifmeta;
|
||||
std::string w = "[";
|
||||
for (size_t i = 0; i < wands.size(); ++i) {
|
||||
const GifMetaWand& a = wands[i];
|
||||
if (i) w += ",";
|
||||
w += "{\"always_cast\":" + jlist(a.always_cast) + ",\"capacity\":" + jnum(a.capacity) + ",\"cast_delay\":" + jnum(a.cast_delay) + ",\"deck\":" + jlist(a.deck) +
|
||||
",\"mana_charge_speed\":" + jnum(a.mana_charge_speed) + ",\"mana_max\":" + jnum(a.mana_max) + ",\"name\":" + jstr(a.name) + ",\"recharge_time\":" + jnum(a.recharge_time) +
|
||||
",\"shuffle\":" + jbool(a.shuffle) + ",\"slot\":" + jnum(a.slot) + ",\"speed_multiplier\":" + jnum(a.speed_multiplier) + ",\"spells_per_cast\":" + jnum(a.spells_per_cast) +
|
||||
",\"spread_degrees\":" + jnum(a.spread_degrees) + "}";
|
||||
}
|
||||
w += "]";
|
||||
return "{\"active_wand\":" + jnum(active_wand) + ",\"build\":" + jstr(build) + ",\"created\":" + jstr(created) + ",\"duration_ms\":" + jnum(duration_ms) +
|
||||
",\"frames\":" + jnum(frames) + ",\"generator\":\"noita_gpu\",\"height\":" + jnum(height) + ",\"mode\":" + jstr(mode) + ",\"seed\":" + jnum((double)seed) +
|
||||
",\"v\":1,\"wands\":" + w + ",\"width\":" + jnum(width) + "}";
|
||||
}
|
||||
};
|
||||
|
||||
// Signs the finished GIF at `path` and inserts the NGPUMETA block after its logical screen descriptor and global colour table. False on an I/O error.
|
||||
inline bool gif_embed_meta(const std::string& path, GifMeta meta) {
|
||||
std::vector<uint8_t> f;
|
||||
{
|
||||
FILE* fp = nullptr;
|
||||
if (fopen_s(&fp, path.c_str(), "rb") != 0 || !fp) return false;
|
||||
std::fseek(fp, 0, SEEK_END); const long n = std::ftell(fp); std::fseek(fp, 0, SEEK_SET);
|
||||
f.resize(n > 0 ? (size_t)n : 0);
|
||||
const bool ok = n > 0 && std::fread(f.data(), 1, f.size(), fp) == f.size();
|
||||
std::fclose(fp);
|
||||
if (!ok || f.size() < 13) return false;
|
||||
}
|
||||
if (meta.created.empty()) {
|
||||
const std::time_t t = std::time(nullptr);
|
||||
std::tm g{};
|
||||
gmtime_s(&g, &t);
|
||||
char b[32];
|
||||
std::snprintf(b, sizeof b, "%04d-%02d-%02dT%02d:%02d:%02dZ", g.tm_year + 1900, g.tm_mon + 1, g.tm_mday, g.tm_hour, g.tm_min, g.tm_sec);
|
||||
meta.created = b;
|
||||
}
|
||||
gifmeta::Sha256 fh;
|
||||
fh.update(f.data(), f.size());
|
||||
const auto d = fh.finish();
|
||||
const std::string canon = meta.canonical();
|
||||
const std::string sig = gifmeta::hmac_sha256_hex(kGifMetaKey, canon + "\n" + gifmeta::hex(d.data(), 32));
|
||||
const std::string json = canon.substr(0, canon.size() - 1) + ",\"sig\":\"" + sig + "\"}";
|
||||
std::vector<uint8_t> blk = {0x21, 0xFF, 0x0B, 'N', 'G', 'P', 'U', 'M', 'E', 'T', 'A', '1', '.', '0'};
|
||||
for (size_t p = 0; p < json.size(); p += 255) {
|
||||
const size_t c = std::min<size_t>(255, json.size() - p);
|
||||
blk.push_back((uint8_t)c);
|
||||
blk.insert(blk.end(), json.begin() + (std::ptrdiff_t)p, json.begin() + (std::ptrdiff_t)(p + c));
|
||||
}
|
||||
blk.push_back(0);
|
||||
size_t at = 13;
|
||||
if (f[10] & 0x80) at += 3u * (2u << (f[10] & 7));
|
||||
if (at > f.size()) return false;
|
||||
f.insert(f.begin() + (std::ptrdiff_t)at, blk.begin(), blk.end());
|
||||
FILE* fp = nullptr;
|
||||
if (fopen_s(&fp, path.c_str(), "wb") != 0 || !fp) return false;
|
||||
const bool ok = std::fwrite(f.data(), 1, f.size(), fp) == f.size();
|
||||
std::fclose(fp);
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace ng
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
#pragma once
|
||||
// Heads-up display drawn on the CPU into an ARGB overlay (1280x720, output pixels) that render.comp blends over the frame.
|
||||
// Text uses an embedded 5x7 bitmap font; icons and bar colours are the game's own HUD images from data.wak.
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "core/png.h"
|
||||
#include "core/wak.h"
|
||||
#include "world/wang_gen.h" // Image, decode_png
|
||||
|
||||
namespace hud {
|
||||
|
||||
// 5x7 font, ASCII 32..126, one byte per column (bit 0 = top row).
|
||||
inline const uint8_t kFont[95][5] = {
|
||||
{0x00,0x00,0x00,0x00,0x00},{0x00,0x00,0x5F,0x00,0x00},{0x00,0x07,0x00,0x07,0x00},{0x14,0x7F,0x14,0x7F,0x14},
|
||||
{0x24,0x2A,0x7F,0x2A,0x12},{0x23,0x13,0x08,0x64,0x62},{0x36,0x49,0x56,0x20,0x50},{0x00,0x08,0x07,0x03,0x00},
|
||||
{0x00,0x1C,0x22,0x41,0x00},{0x00,0x41,0x22,0x1C,0x00},{0x2A,0x1C,0x7F,0x1C,0x2A},{0x08,0x08,0x3E,0x08,0x08},
|
||||
{0x00,0x80,0x70,0x30,0x00},{0x08,0x08,0x08,0x08,0x08},{0x00,0x00,0x60,0x60,0x00},{0x20,0x10,0x08,0x04,0x02},
|
||||
{0x3E,0x51,0x49,0x45,0x3E},{0x00,0x42,0x7F,0x40,0x00},{0x72,0x49,0x49,0x49,0x46},{0x21,0x41,0x49,0x4D,0x33},
|
||||
{0x18,0x14,0x12,0x7F,0x10},{0x27,0x45,0x45,0x45,0x39},{0x3C,0x4A,0x49,0x49,0x31},{0x41,0x21,0x11,0x09,0x07},
|
||||
{0x36,0x49,0x49,0x49,0x36},{0x46,0x49,0x49,0x29,0x1E},{0x00,0x00,0x14,0x00,0x00},{0x00,0x40,0x34,0x00,0x00},
|
||||
{0x00,0x08,0x14,0x22,0x41},{0x14,0x14,0x14,0x14,0x14},{0x00,0x41,0x22,0x14,0x08},{0x02,0x01,0x59,0x09,0x06},
|
||||
{0x3E,0x41,0x5D,0x59,0x4E},{0x7C,0x12,0x11,0x12,0x7C},{0x7F,0x49,0x49,0x49,0x36},{0x3E,0x41,0x41,0x41,0x22},
|
||||
{0x7F,0x41,0x41,0x41,0x3E},{0x7F,0x49,0x49,0x49,0x41},{0x7F,0x09,0x09,0x09,0x01},{0x3E,0x41,0x41,0x51,0x73},
|
||||
{0x7F,0x08,0x08,0x08,0x7F},{0x00,0x41,0x7F,0x41,0x00},{0x20,0x40,0x41,0x3F,0x01},{0x7F,0x08,0x14,0x22,0x41},
|
||||
{0x7F,0x40,0x40,0x40,0x40},{0x7F,0x02,0x1C,0x02,0x7F},{0x7F,0x04,0x08,0x10,0x7F},{0x3E,0x41,0x41,0x41,0x3E},
|
||||
{0x7F,0x09,0x09,0x09,0x06},{0x3E,0x41,0x51,0x21,0x5E},{0x7F,0x09,0x19,0x29,0x46},{0x26,0x49,0x49,0x49,0x32},
|
||||
{0x03,0x01,0x7F,0x01,0x03},{0x3F,0x40,0x40,0x40,0x3F},{0x1F,0x20,0x40,0x20,0x1F},{0x3F,0x40,0x38,0x40,0x3F},
|
||||
{0x63,0x14,0x08,0x14,0x63},{0x03,0x04,0x78,0x04,0x03},{0x61,0x59,0x49,0x4D,0x43},{0x00,0x7F,0x41,0x41,0x41},
|
||||
{0x02,0x04,0x08,0x10,0x20},{0x41,0x41,0x41,0x7F,0x00},{0x04,0x02,0x01,0x02,0x04},{0x40,0x40,0x40,0x40,0x40},
|
||||
{0x00,0x03,0x07,0x08,0x00},{0x20,0x54,0x54,0x78,0x40},{0x7F,0x28,0x44,0x44,0x38},{0x38,0x44,0x44,0x44,0x28},
|
||||
{0x38,0x44,0x44,0x28,0x7F},{0x38,0x54,0x54,0x54,0x18},{0x00,0x08,0x7E,0x09,0x02},{0x18,0xA4,0xA4,0x9C,0x78},
|
||||
{0x7F,0x08,0x04,0x04,0x78},{0x00,0x44,0x7D,0x40,0x00},{0x20,0x40,0x40,0x3D,0x00},{0x7F,0x10,0x28,0x44,0x00},
|
||||
{0x00,0x41,0x7F,0x40,0x00},{0x7C,0x04,0x78,0x04,0x78},{0x7C,0x08,0x04,0x04,0x78},{0x38,0x44,0x44,0x44,0x38},
|
||||
{0xFC,0x18,0x24,0x24,0x18},{0x18,0x24,0x24,0x18,0xFC},{0x7C,0x08,0x04,0x04,0x08},{0x48,0x54,0x54,0x54,0x24},
|
||||
{0x04,0x04,0x3F,0x44,0x24},{0x3C,0x40,0x40,0x20,0x7C},{0x1C,0x20,0x40,0x20,0x1C},{0x3C,0x40,0x30,0x40,0x3C},
|
||||
{0x44,0x28,0x10,0x28,0x44},{0x4C,0x90,0x90,0x90,0x7C},{0x44,0x64,0x54,0x4C,0x44},{0x00,0x08,0x36,0x41,0x00},
|
||||
{0x00,0x00,0x77,0x00,0x00},{0x00,0x41,0x36,0x08,0x00},{0x02,0x01,0x02,0x04,0x02},
|
||||
};
|
||||
|
||||
struct Icon {
|
||||
int w = 0, h = 0;
|
||||
std::vector<uint32_t> px; // ARGB
|
||||
bool ok() const { return w > 0; }
|
||||
};
|
||||
|
||||
inline Icon load_icon(const ng::Wak& wak, const std::string& path) {
|
||||
Icon ic;
|
||||
std::vector<uint8_t> raw;
|
||||
ng::Image im;
|
||||
if (!wak.read(path, raw) || !ng::decode_png(raw.data(), raw.size(), im)) return ic;
|
||||
ic.w = im.width; ic.h = im.height;
|
||||
ic.px.resize((size_t)ic.w * ic.h);
|
||||
for (size_t i = 0; i < ic.px.size(); ++i) {
|
||||
const uint8_t* p = &im.rgba[i * 4];
|
||||
ic.px[i] = (uint32_t)p[3] << 24 | (uint32_t)p[0] << 16 | (uint32_t)p[1] << 8 | p[2];
|
||||
}
|
||||
return ic;
|
||||
}
|
||||
|
||||
// The game's bitmap fonts (data/fonts/*.xml: FontData with a texture and a QuadChar per character): the UI and the text sprites of the world (shop prices) use font_pixel_white.
|
||||
struct GameFont {
|
||||
struct Glyph { int rx = 0, ry = 0, rw = 0, rh = 0, ox = 0, oy = 0, width = 0; bool ok = false; };
|
||||
Icon tex;
|
||||
Glyph g[256];
|
||||
int line_height = 7, char_space = 0, word_space = 6;
|
||||
bool ok() const { return tex.ok(); }
|
||||
};
|
||||
inline GameFont load_font(const ng::Wak& wak, const std::string& xml_path) {
|
||||
GameFont f;
|
||||
std::vector<uint8_t> raw;
|
||||
if (!wak.read(xml_path, raw)) return f;
|
||||
const std::string s(raw.begin(), raw.end());
|
||||
auto tagText = [&](const char* tag) { const std::string o = std::string("<") + tag + ">", c = std::string("</") + tag + ">"; const size_t a = s.find(o), b = s.find(c); if (a == std::string::npos || b == std::string::npos) return std::string(); std::string t = s.substr(a + o.size(), b - a - o.size()); const size_t i0 = t.find_first_not_of(" \t\r\n"), i1 = t.find_last_not_of(" \t\r\n"); return i0 == std::string::npos ? std::string() : t.substr(i0, i1 - i0 + 1); };
|
||||
const std::string texPath = tagText("Texture");
|
||||
if (!tagText("LineHeight").empty()) f.line_height = std::atoi(tagText("LineHeight").c_str());
|
||||
if (!tagText("CharSpace").empty()) f.char_space = std::atoi(tagText("CharSpace").c_str());
|
||||
if (!tagText("WordSpace").empty()) f.word_space = std::atoi(tagText("WordSpace").c_str());
|
||||
for (size_t pos = s.find("<QuadChar"); pos != std::string::npos; pos = s.find("<QuadChar", pos + 1)) {
|
||||
const size_t end = s.find('>', pos);
|
||||
const std::string q = s.substr(pos, end - pos);
|
||||
auto at = [&](const char* k) { const std::string key = std::string(" ") + k + "=\""; const size_t a = q.find(key); return a == std::string::npos ? 0 : std::atoi(q.c_str() + a + key.size()); };
|
||||
const int id = at("id");
|
||||
if (id < 0 || id > 255) continue;
|
||||
GameFont::Glyph& gl = f.g[id];
|
||||
gl.rx = at("rect_x"); gl.ry = at("rect_y"); gl.rw = at("rect_w"); gl.rh = at("rect_h"); gl.ox = at("offset_x"); gl.oy = at("offset_y"); gl.width = at("width"); gl.ok = true;
|
||||
}
|
||||
if (!texPath.empty()) f.tex = load_icon(wak, texPath);
|
||||
return f;
|
||||
}
|
||||
|
||||
constexpr uint32_t rgba(int r, int g, int b, int a = 255) { return (uint32_t)a << 24 | (uint32_t)r << 16 | (uint32_t)g << 8 | (uint32_t)b; }
|
||||
|
||||
// Draws into a caller-owned ARGB buffer (premultiplication is not used: the shader does a plain alpha mix).
|
||||
struct Canvas {
|
||||
uint32_t* px = nullptr;
|
||||
int w = 0, h = 0;
|
||||
const GameFont* font = nullptr; // the game's font when it is loaded (else the embedded 5x7)
|
||||
|
||||
// The 32 x 32 tiles drawn into since the last clear: clear() zeroes only those, main.cpp uploads only those and render.comp reads the overlay only
|
||||
// there (the HUD covers a few percent of the window; the whole 14 MB buffer was cleared, copied to mapped memory and read over PCIe every frame).
|
||||
std::vector<uint8_t> tiles;
|
||||
int tw = 0, th = 0;
|
||||
void mark(int x, int y) { tiles[(size_t)(y >> 5) * tw + (x >> 5)] = 1; }
|
||||
void mark_all() { std::fill(tiles.begin(), tiles.end(), (uint8_t)1); } // (a drawer that writes px directly over the whole window: the replay editor)
|
||||
void clear() {
|
||||
const int ntw = (w + 31) >> 5, nth = (h + 31) >> 5;
|
||||
if (ntw != tw || nth != th) { tw = ntw; th = nth; tiles.assign((size_t)tw * th, 0); std::memset(px, 0, (size_t)w * h * 4); return; } // (a new size: a new row stride)
|
||||
for (int ty = 0; ty < th; ++ty) for (int tx = 0; tx < tw; ++tx) {
|
||||
uint8_t& m = tiles[(size_t)ty * tw + tx];
|
||||
if (!m) continue;
|
||||
m = 0;
|
||||
const int x0 = tx * 32, n = std::min(32, w - x0);
|
||||
for (int y = ty * 32; y < std::min(h, ty * 32 + 32); ++y) std::memset(px + (size_t)y * w + x0, 0, (size_t)n * 4);
|
||||
}
|
||||
}
|
||||
|
||||
void put(int x, int y, uint32_t c) {
|
||||
if (x < 0 || y < 0 || x >= w || y >= h) return;
|
||||
const uint32_t a = c >> 24;
|
||||
if (a == 0) return;
|
||||
mark(x, y);
|
||||
uint32_t& d = px[(size_t)y * w + x];
|
||||
if (a == 255 || (d >> 24) == 0) { d = c; return; }
|
||||
// blend over an existing overlay pixel (keep the larger alpha)
|
||||
const uint32_t da = d >> 24;
|
||||
const uint32_t oa = a + da * (255 - a) / 255;
|
||||
auto mix = [&](int sh) { return ((c >> sh & 255) * a + (d >> sh & 255) * da * (255 - a) / 255) / std::max<uint32_t>(oa, 1); };
|
||||
d = oa << 24 | mix(16) << 16 | mix(8) << 8 | mix(0);
|
||||
}
|
||||
void rect(int x, int y, int rw, int rh, uint32_t c) {
|
||||
for (int j = 0; j < rh; ++j) for (int i = 0; i < rw; ++i) put(x + i, y + j, c);
|
||||
}
|
||||
void frame(int x, int y, int rw, int rh, uint32_t c) {
|
||||
rect(x, y, rw, 1, c); rect(x, y + rh - 1, rw, 1, c); rect(x, y, 1, rh, c); rect(x + rw - 1, y, 1, rh, c);
|
||||
}
|
||||
int text_width(const std::string& s, int scale) const {
|
||||
if (font && font->ok()) { int w = 0; for (unsigned char ch : s) { const GameFont::Glyph& gl = font->g[ch]; w += (gl.ok ? gl.width : font->word_space) + font->char_space; } return w * scale; }
|
||||
return (int)s.size() * 6 * scale - scale;
|
||||
}
|
||||
void text(int x, int y, const std::string& s, uint32_t c, int scale = 2, bool shadow = true) {
|
||||
if (font && font->ok()) { // the game's font: its texture holds the glyph (white) and its shadow (black): the white pixels take the colour, the black ones stay black
|
||||
const Icon& t = font->tex;
|
||||
const uint32_t shade = rgba(0, 0, 0, (int)std::min<uint32_t>(220u, c >> 24)); // (the shadow fades with the text; without `shadow` it is left out)
|
||||
for (int pass = 1; pass < 2; ++pass) {
|
||||
const int off = 0;
|
||||
int cx = x + off;
|
||||
for (unsigned char ch : s) {
|
||||
const GameFont::Glyph& gl = font->g[ch];
|
||||
if (gl.ok) for (int j = 0; j < gl.rh; ++j) for (int i = 0; i < gl.rw; ++i) {
|
||||
const int sx = gl.rx + i, sy = gl.ry + j;
|
||||
if (sx < 0 || sy < 0 || sx >= t.w || sy >= t.h) continue;
|
||||
const uint32_t tp = t.px[(size_t)sy * t.w + sx];
|
||||
if ((tp >> 24) < 128) continue;
|
||||
const bool dark = (tp & 0xFFFFFFu) < 0x404040u;
|
||||
if (dark && !shadow) continue;
|
||||
rect(cx + (gl.ox + i) * scale, y + off + (gl.oy + j) * scale, scale, scale, dark ? shade : c);
|
||||
}
|
||||
cx += ((gl.ok ? gl.width : font->word_space) + font->char_space) * scale;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (int pass = shadow ? 0 : 1; pass < 2; ++pass) {
|
||||
const uint32_t col = pass == 0 ? rgba(0, 0, 0, (int)std::min<uint32_t>(200u, c >> 24)) : c; // (the shadow fades with the text)
|
||||
const int off = pass == 0 ? scale : 0;
|
||||
int cx = x + off;
|
||||
for (char ch : s) {
|
||||
if (ch >= 32 && ch < 127) {
|
||||
const uint8_t* g = kFont[ch - 32];
|
||||
for (int col_i = 0; col_i < 5; ++col_i)
|
||||
for (int row = 0; row < 8; ++row)
|
||||
if (g[col_i] >> row & 1) rect(cx + col_i * scale, y + off + row * scale, scale, scale, col);
|
||||
}
|
||||
cx += 6 * scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
void icon(const Icon& ic, int x, int y, int scale) {
|
||||
for (int j = 0; j < ic.h; ++j)
|
||||
for (int i = 0; i < ic.w; ++i) {
|
||||
const uint32_t c = ic.px[(size_t)j * ic.w + i];
|
||||
if (c >> 24) rect(x + i * scale, y + j * scale, scale, scale, c);
|
||||
}
|
||||
}
|
||||
// Draws an icon stretched to w x h output pixels (nearest texel: the slots of the information boxes, 0.6 of the UI scale)
|
||||
void icon_fit(const Icon& ic, int x, int y, int w, int h) {
|
||||
if (ic.w <= 0 || ic.h <= 0 || w <= 0 || h <= 0) return;
|
||||
for (int j = 0; j < ic.h; ++j) {
|
||||
const int y0 = y + j * h / ic.h, y1 = y + (j + 1) * h / ic.h;
|
||||
for (int i = 0; i < ic.w; ++i) {
|
||||
const uint32_t c = ic.px[(size_t)j * ic.w + i];
|
||||
if (!(c >> 24)) continue;
|
||||
const int x0 = x + i * w / ic.w, x1 = x + (i + 1) * w / ic.w;
|
||||
if (x1 > x0 && y1 > y0) rect(x0, y0, x1 - x0, y1 - y0, c);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Draws texels (ARGB, w x h) centred on (cx, cy).
|
||||
void blit_centered(const uint32_t* tex, int tw, int th, int cx, int cy, int scale) {
|
||||
for (int j = 0; j < th; ++j)
|
||||
for (int i = 0; i < tw; ++i) {
|
||||
const uint32_t c = tex[(size_t)j * tw + i];
|
||||
if (c >> 24) rect(cx + (i - tw / 2) * scale, cy + (j - th / 2) * scale, scale, scale, c);
|
||||
}
|
||||
}
|
||||
// Draws texels rotated by `angle` (radians) around the pivot pixel (px, py) of the image, placed at (x, y); mirror flips the image vertically.
|
||||
void blit_rotated(const uint32_t* tex, int tw, int th, int px, int py, double x, double y, double angle, int scale, bool mirror) {
|
||||
const double ca = std::cos(angle), sa = std::sin(angle);
|
||||
const int rad = (int)std::ceil(std::hypot((double)tw, (double)th)) * scale + 2;
|
||||
for (int dy = -rad; dy <= rad; ++dy)
|
||||
for (int dx = -rad; dx <= rad; ++dx) {
|
||||
// inverse rotation of the destination offset into image space
|
||||
const double ix = (dx * ca + dy * sa) / scale + px, iy = (-dx * sa + dy * ca) / scale + py;
|
||||
int sx = (int)std::floor(ix), sy = (int)std::floor(iy);
|
||||
if (mirror) sy = th - 1 - sy + 0;
|
||||
if (sx < 0 || sy < 0 || sx >= tw || sy >= th) continue;
|
||||
const uint32_t c = tex[(size_t)sy * tw + sx];
|
||||
if (c >> 24) put((int)std::lround(x) + dx, (int)std::lround(y) + dy, c);
|
||||
}
|
||||
}
|
||||
// Horizontal bar: dark frame, background, filled part.
|
||||
void bar(int x, int y, int bw, int bh, float frac, uint32_t fill, uint32_t back) {
|
||||
frac = std::clamp(frac, 0.0f, 1.0f);
|
||||
rect(x - 2, y - 2, bw + 4, bh + 4, rgba(0, 0, 0, 220));
|
||||
rect(x, y, bw, bh, back);
|
||||
rect(x, y, (int)(bw * frac + 0.5f), bh, fill);
|
||||
}
|
||||
};
|
||||
|
||||
struct Enemy { int sx, sy; float hp, maxhp; };
|
||||
struct BossBar { float hp, maxhp; }; // a BossHealthBarComponent bar on the screen (00a58380)
|
||||
|
||||
// Everything the HUD shows for one frame.
|
||||
struct State {
|
||||
bool world = false, has_player = false, paused = false, brush_mode = false, free_cam = false, debug = false, help = false;
|
||||
float hp = 4, maxhp = 4, energy = 1; // hp in game units (x25 on screen), energy 0..1
|
||||
double px = 0, py = 0;
|
||||
int view_x = 0, view_y = 0;
|
||||
int mouse_x = 0, mouse_y = 0; // output pixels
|
||||
int brush_select = 0, brush_radius = 6;
|
||||
const char* const* palette_names = nullptr;
|
||||
const uint32_t* palette_colors = nullptr; // ARGB swatches of the 8 brush materials
|
||||
double fps = 0;
|
||||
double gpu_ms = 0, frame_ms = 0; // GPU time of the last frame (timestamp queries), wall time between frames
|
||||
int sim_speed = 1; // cell simulation ticks per frame
|
||||
bool show_timing = false; // always-on timing line (sandbox)
|
||||
int entities = 0, stored_tiles = 0, frame = 0;
|
||||
int death_timer = 0; // frames left of the "you died" message
|
||||
std::vector<Enemy> enemies;
|
||||
std::vector<BossBar> boss_bars; // (main.cpp: the bosses within gui_max_distance_visible)
|
||||
int ui_frame = 0; // (the blink of a low boss bar)
|
||||
// the mod GUI (Gui* of the Lua API) in GUI units: gui_scale window pixels each; kind 0 text, 1 image, 2 nine-piece
|
||||
struct GuiDraw { int kind = 0; float x = 0, y = 0, w = 0, h = 0, scale = 1, alpha = 1; uint32_t color = 0xFFFFFFFFu; std::string text; const Icon* icon = nullptr; };
|
||||
std::vector<GuiDraw> gui;
|
||||
int gui_scale = 2;
|
||||
struct WorldText { int x = 0, y = 0; std::string text; uint32_t color = 0xFFFFFFFFu; int scale = 2; bool dmg_font = false; }; // damage numbers (font_damage, centred on x and y, no shadow) and BLOCK (centred on x)
|
||||
std::vector<WorldText> damage_texts;
|
||||
int money = 0;
|
||||
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
|
||||
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
|
||||
std::vector<int> spell_uses; // their uses left (-1: unlimited), drawn in the corner like in the inventory
|
||||
float mana = 0, mana_max = 0;
|
||||
float air = 1.0f; // breath left (1 = full)
|
||||
bool burning = false;
|
||||
int reload_left = 0, cooldown_left = 0;
|
||||
struct ProjDraw { int sx = 0, sy = 0; const uint32_t* tex = nullptr; int w = 0, h = 0; uint32_t color = 0xFFFFD040u; };
|
||||
std::vector<ProjDraw> projectiles;
|
||||
// the wand held in the hand: sprite, pivot in the sprite, hand position and aim angle (output pixels / radians)
|
||||
const uint32_t* hand_tex = nullptr; int hand_w = 0, hand_h = 0, hand_px = 0, hand_py = 0, hand_x = 0, hand_y = 0; double hand_angle = 0; bool hand_mirror = false;
|
||||
std::vector<std::string> log_lines; // recent game messages, newest last
|
||||
std::vector<std::string> inventory; // names of picked up items
|
||||
std::string hint; // e.g. "E Open chest" next to the player
|
||||
int hint_x = -1, hint_y = -1; // output pixels
|
||||
uint32_t hint_color = 0xFFFFFFFFu;
|
||||
const char* seed_text = "";
|
||||
// ---- the open inventory (wand editing): cells are laid out and hit-tested by the host, drawn here ----
|
||||
struct InvCell { int x = 0, y = 0, w = 0, h = 0; bool draw = true; int bg = -1; const Icon* icon = nullptr; float fill = -1.0f; bool hover = false, selected = false, locked = false, ghost = false; bool frame = true; std::string label; };
|
||||
struct InvPanel { int x = 0, y = 0, w = 0, h = 0; int style = 0; bool active = false; }; // style 0: the brown frame of a bar, 1: the dark box of a wand
|
||||
struct InvText { int x = 0, y = 0; std::string text; uint32_t color = 0xFFFFFFFFu; int scale = 1; };
|
||||
struct TipLine { std::string text; uint32_t color = 0xFFFFFFFFu; const Icon* icon = nullptr; std::string value; };
|
||||
struct StatusIcon { const Icon* icon = nullptr; std::string name, desc; float alpha = 1.0f; };
|
||||
std::vector<StatusIcon> statuses; // the active statuses of the player (stains, ingestion, effects with a UIIconComponent display_in_hud): icons left of the bars, the text on hover
|
||||
struct HeadIcon { const Icon* icon = nullptr; int x = 0, y = 0, scale = 1; };
|
||||
std::vector<HeadIcon> head_icons; // the status icons over the player's head (UIIconComponent display_above_head, the stains), top-left corner in output pixels
|
||||
bool inv_open = false;
|
||||
int hover_slot = -1; // quick slot (0-7) under the mouse
|
||||
std::vector<InvCell> inv_cells;
|
||||
std::vector<InvPanel> inv_panels;
|
||||
bool tip_fixed = false; int tip_w = 0; const Icon* tip_icon = nullptr; // the information box at a fixed place (width in output pixels, a big icon on its right)
|
||||
std::vector<InvText> inv_texts;
|
||||
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;
|
||||
// ---- 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 ----
|
||||
struct Layer { bool dim = false; int dim_alpha = 120; std::vector<InvPanel> panels; std::vector<InvCell> cells; std::vector<InvText> texts; std::vector<InvPanel> shades; }; // (shades: darkened over everything of the layer - the slot under the mouse in the new-wand choice)
|
||||
std::vector<Layer> layers;
|
||||
};
|
||||
|
||||
struct Assets {
|
||||
Icon heart, jetpack, box, boxHi, mana, money, itemBg[8], inactive, boxQuickHi, importantMsg;
|
||||
GameFont font, dmgFont;
|
||||
bool loaded = false;
|
||||
void load(const ng::Wak& wak) {
|
||||
font = load_font(wak, "data/fonts/font_pixel_white.xml");
|
||||
dmgFont = load_font(wak, "data/fonts/font_damage.xml"); // REPORT_DAMAGE_FONT: red digits and "-" only
|
||||
if (std::getenv("NG_FONTLOG")) { int n = 0; for (const auto& gl : dmgFont.g) n += gl.ok; std::printf("font_damage: texture %dx%d, %d glyphs%c", dmgFont.tex.w, dmgFont.tex.h, n, 10); }
|
||||
heart = load_icon(wak, "data/ui_gfx/hud/health.png");
|
||||
importantMsg = load_icon(wak, "data/ui_gfx/decorations/3piece_important_msg.png"); // (the banner of the new-wand choice: 3 pieces of 24, the middle stretched)
|
||||
jetpack = load_icon(wak, "data/ui_gfx/hud/jetpack.png");
|
||||
box = load_icon(wak, "data/ui_gfx/inventory/full_inventory_box.png");
|
||||
money = load_icon(wak, "data/ui_gfx/hud/money.png");
|
||||
boxHi = load_icon(wak, "data/ui_gfx/inventory/full_inventory_box_highlight.png");
|
||||
mana = load_icon(wak, "data/ui_gfx/hud/mana.png");
|
||||
static const char* const bgs[8] = {"projectile", "static_projectile", "modifier", "draw_many", "material", "other", "utility", "passive"}; // by spell type
|
||||
for (int i = 0; i < 8; ++i) itemBg[i] = load_icon(wak, std::string("data/ui_gfx/inventory/item_bg_") + bgs[i] + ".png");
|
||||
inactive = load_icon(wak, "data/ui_gfx/inventory/inventory_box_inactive_overlay.png");
|
||||
boxQuickHi = load_icon(wak, "data/ui_gfx/inventory/quick_inventory_box_hovered.png");
|
||||
loaded = true;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::string fmt(const char* f, double a) { char b[64]; std::snprintf(b, sizeof b, f, a); return b; }
|
||||
|
||||
// Draws the whole HUD. Coordinates are output pixels (1280x720).
|
||||
inline void draw(Canvas& cv, const Assets& as, const State& s) {
|
||||
cv.clear();
|
||||
cv.font = as.font.ok() ? &as.font : nullptr;
|
||||
for (const State::InvText& t : s.world_texts) cv.text(t.x, t.y, t.text, t.color, t.scale, false);
|
||||
const int uiS = std::max(1, (int)std::lround(cv.w * 2.8 / 2000.0)), tsS = std::max(1, uiS * 2 / 3);
|
||||
auto drawCell = [&](const State::InvCell& c, int ui, int ts) {
|
||||
if (!c.draw) return;
|
||||
const Icon& frame = c.hover ? as.boxHi : as.box;
|
||||
const Icon* fr = c.frame ? ((c.bg >= 0 && c.bg < 8 && as.itemBg[c.bg].ok()) ? &as.itemBg[c.bg] : &frame) : nullptr;
|
||||
const bool exact = fr && fr->w * ui == c.w; // (a slot at the UI scale: the art as it is; a smaller one - the information boxes, 0.6 - stretched to the cell, its icon to 16 / 20 of it)
|
||||
if (fr) { if (exact) cv.icon(*fr, c.x, c.y, ui); else cv.icon_fit(*fr, c.x, c.y, c.w, c.h); }
|
||||
if (c.icon && c.icon->ok()) {
|
||||
if (c.frame && !exact) { const int iw = c.w * c.icon->w / 20, ih = c.h * c.icon->h / 20; cv.icon_fit(*c.icon, c.x + (c.w - iw) / 2, c.y + (c.h - ih) / 2, iw, ih); }
|
||||
else cv.blit_centered(c.icon->px.data(), c.icon->w, c.icon->h, c.x + c.w / 2, c.y + c.h / 2, c.frame ? std::max(1, std::min(ui, (c.w * 17 / 20) / std::max(c.icon->w, 1))) : std::max(1, c.w / std::max(c.icon->w, 1)));
|
||||
}
|
||||
if (c.hover && c.bg >= 0) cv.frame(c.x, c.y, c.w, c.h, rgba(255, 255, 255, 220));
|
||||
if (c.selected) cv.frame(c.x - ui, c.y - ui, c.w + 2 * ui, c.h + 2 * ui, rgba(255, 220, 120, 230));
|
||||
if (c.locked && as.inactive.ok()) cv.icon(as.inactive, c.x, c.y, ui);
|
||||
if (c.ghost) cv.rect(c.x, c.y, c.w, c.h, rgba(0, 0, 0, 150));
|
||||
if (!c.label.empty()) cv.text(c.x + 2 * ui, c.y + 2 * ui, c.label, rgba(255, 255, 255), std::max(1, ts));
|
||||
};
|
||||
const uint32_t white = rgba(255, 255, 255), grey = rgba(190, 190, 200), red = rgba(210, 40, 40), dark = rgba(40, 20, 20, 230);
|
||||
if (s.inv_open && s.has_player) {
|
||||
cv.rect(0, 0, cv.w, cv.h, rgba(0, 0, 0, 120)); // the inventory dims the world
|
||||
for (const State::InvPanel& p : s.inv_panels) {
|
||||
if (p.style == 0) { cv.rect(p.x, p.y, p.w, p.h, rgba(60, 47, 42, 255)); cv.frame(p.x, p.y, p.w, p.h, rgba(79, 50, 45, 255)); } // (the game's colours, sampled at 2560 x 1440)
|
||||
else { cv.rect(p.x, p.y, p.w, p.h, rgba(4, 3, 4, 245)); cv.frame(p.x, p.y, p.w, p.h, p.active ? rgba(255, 160, 40, 255) : rgba(200, 190, 170, 255)); if (p.active) cv.frame(p.x + 1, p.y + 1, p.w - 2, p.h - 2, rgba(255, 160, 40, 255)); }
|
||||
}
|
||||
}
|
||||
// ---- the status icons over the player's head ----
|
||||
for (const State::HeadIcon& hi : s.head_icons) if (hi.icon && hi.icon->ok()) cv.icon(*hi.icon, hi.x, hi.y, hi.scale);
|
||||
// ---- the damage numbers / BLOCK rising over the entities (00ab9a90 / 00b071f0) ----
|
||||
for (const State::WorldText& wt : s.damage_texts) {
|
||||
if (wt.dmg_font && as.dmgFont.ok()) {
|
||||
const GameFont* keep = cv.font;
|
||||
cv.font = &as.dmgFont;
|
||||
cv.text(wt.x - cv.text_width(wt.text, wt.scale) / 2, wt.y - as.dmgFont.line_height * wt.scale / 2, wt.text, wt.color, wt.scale, false);
|
||||
cv.font = keep;
|
||||
} else cv.text(wt.x - cv.text_width(wt.text, wt.scale) / 2, wt.y, wt.text, wt.color, wt.scale);
|
||||
}
|
||||
// TODO(to-do #5): the bar widget details (damage chunk, fade, name), the final boss variant, in_world slider scaling
|
||||
// ---- boss bars (BossHealthBarComponent, 00a58380 / 00b7cbf0): centred, at 0.8 of the screen height in virtual pixels and 24 higher for each further boss; the
|
||||
// width 20 log10(2.5 max hp); the player's bar art (frame, back, fill), the heart and the hp; below 0.25 hp the fill blinks every 30 frames in the damage colour
|
||||
{
|
||||
const int ui = std::max(1, (int)std::lround(cv.w * 2.8 / 2000.0));
|
||||
const int ts = std::max(1, ui * 2 / 3);
|
||||
for (size_t k = 0; k < s.boss_bars.size(); ++k) {
|
||||
const BossBar& b = s.boss_bars[k];
|
||||
const float wv = std::clamp(20.0f * std::log10(std::max(2.5f * b.maxhp, 1.0f)), 8.0f, 200.0f);
|
||||
const int bw = (int)(wv * ui), bh = 6 * ui;
|
||||
const int cy = (int)((cv.h / (float)ui * 0.8f - 24.0f * (float)k) * ui), bx = cv.w / 2 - bw / 2, top = cy - bh / 2;
|
||||
const bool blink = b.hp < 0.25f && (s.ui_frame / 30) % 2 == 1;
|
||||
cv.rect(bx, top, bw, bh, rgba(121, 71, 56));
|
||||
cv.rect(bx + ui, top + ui, bw - 2 * ui, bh - 2 * ui, b.hp < 0.25f && !blink ? rgba(110, 30, 30, 235) : rgba(58, 36, 31, 235));
|
||||
cv.rect(bx + ui, top + ui, (int)((bw - 2 * ui) * std::clamp(b.hp / std::max(b.maxhp, 0.001f), 0.0f, 1.0f)), bh - 2 * ui, blink ? rgba(255, 255, 255) : rgba(135, 191, 28));
|
||||
if (as.heart.ok()) cv.icon(as.heart, bx - 10 * ui, cy - 4 * ui, ui);
|
||||
const std::string hp = std::to_string((int)std::lround(std::max(b.hp, 0.0f) * 25.0f));
|
||||
cv.text(bx + bw + 3 * ui, cy - 3 * ts, hp, rgba(255, 255, 255), ts);
|
||||
}
|
||||
}
|
||||
// ---- health bars (HealthBarComponent: the bosses) ----
|
||||
for (const Enemy& e : s.enemies) {
|
||||
const int bw = 28;
|
||||
cv.rect(e.sx - bw / 2 - 1, e.sy - 1, bw + 2, 5, rgba(0, 0, 0, 200));
|
||||
cv.rect(e.sx - bw / 2, e.sy, bw, 3, dark);
|
||||
cv.rect(e.sx - bw / 2, e.sy, (int)(bw * std::clamp(e.hp / std::max(e.maxhp, 0.001f), 0.0f, 1.0f)), 3, red);
|
||||
}
|
||||
if (s.has_player) {
|
||||
// The layout of the game (measured on a 2000x1088 screenshot, where the HUD is drawn at 2.8 pixels per asset pixel): the quick inventory top left,
|
||||
// the health / flight / mana bars with their icons top right, the gold under them.
|
||||
const int ui = std::max(1, (int)std::lround(cv.w * 2.8 / 2000.0)); // output pixels per asset pixel
|
||||
const int ts = std::max(1, ui * 2 / 3); // text scale
|
||||
const int slot = 20 * ui, pitch = (int)(22.5 * ui), x0 = 23 * ui, y0 = 23 * ui;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const int x = x0 + i * pitch;
|
||||
cv.icon(((s.active_slot >= 0 ? i == s.active_slot : i == s.active_wand) || i == s.hover_slot) ? as.boxHi : as.box, x, y0, ui);
|
||||
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;
|
||||
cv.blit_centered(ic.px.data(), ic.w, ic.h, x + slot / 2, y0 + slot / 2, std::max(1, std::min(ui, (slot * 3 / 4) / std::max(ic.w, 1))));
|
||||
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
|
||||
}
|
||||
}
|
||||
// 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) {
|
||||
const int x = x0 + (int)i * 18 * ui;
|
||||
cv.rect(x, sy, 17 * ui, 17 * ui, rgba(0, 0, 0, 150));
|
||||
if (s.spell_icons[i] && s.spell_icons[i]->ok()) cv.blit_centered(s.spell_icons[i]->px.data(), s.spell_icons[i]->w, s.spell_icons[i]->h, x + 17 * ui / 2, sy + 17 * ui / 2, ui);
|
||||
if (i < s.spell_uses.size() && s.spell_uses[i] >= 0) cv.text(x + 2 * ui, sy + 2 * ui, std::to_string(s.spell_uses[i]), rgba(255, 255, 255), std::max(1, ts));
|
||||
}
|
||||
if (s.inv_open) {
|
||||
} else if (s.active_slot >= 4 && s.active_slot - 4 < (int)s.items.size()) {
|
||||
cv.text(x0, sy + (s.spell_icons.empty() ? 0 : 19 * ui), s.items[s.active_slot - 4].name, white, ts);
|
||||
} else if (s.active_wand >= 0 && s.active_wand < (int)s.wands.size()) {
|
||||
const auto& w = s.wands[s.active_wand];
|
||||
cv.text(x0, sy + (s.spell_icons.empty() ? 0 : 19 * ui), w.name.empty() ? "Wand" : w.name, white, ts);
|
||||
}
|
||||
if (s.inv_open) { // the wand rows, the full inventory and what is on the cursor
|
||||
for (const State::InvText& t : s.inv_texts) cv.text(t.x, t.y, t.text, t.color, t.scale);
|
||||
for (const State::InvCell& c : s.inv_cells) drawCell(c, ui, ts);
|
||||
if (!s.tip.empty()) { // hover information: a dark box with the name, the description and the numbers
|
||||
const int ts = std::max(2, ui * 3 / 4); // (the information box has bigger text than the rest)
|
||||
int tw = 0;
|
||||
for (const State::TipLine& l : s.tip) tw = std::max(tw, cv.text_width(l.text, ts) + (l.value.empty() ? 0 : cv.text_width(l.value, ts) + 10 * ts) + (l.icon ? 9 * ts : 0));
|
||||
const int lh = 9 * ts, bw = s.tip_fixed ? s.tip_w : tw + 8 * ts, bh = (int)s.tip.size() * lh + 6 * ts;
|
||||
const int bx = s.tip_fixed ? s.tip_x : std::clamp(s.tip_x, 4, std::max(4, cv.w - bw - 4)), by = s.tip_fixed ? s.tip_y : std::clamp(s.tip_y, 4, std::max(4, cv.h - bh - 4));
|
||||
cv.rect(bx, by, bw, bh, rgba(12, 10, 12, 235));
|
||||
cv.frame(bx, by, bw, bh, rgba(150, 130, 110, 255));
|
||||
if (s.tip_icon && s.tip_icon->ok()) cv.blit_centered(s.tip_icon->px.data(), s.tip_icon->w, s.tip_icon->h, bx + bw - 28 * ui, by + bh / 2, ui);
|
||||
int ty = by + 3 * ts;
|
||||
for (const State::TipLine& l : s.tip) {
|
||||
int tx = bx + 4 * ts;
|
||||
if (l.icon && l.icon->ok()) { cv.blit_centered(l.icon->px.data(), l.icon->w, l.icon->h, tx + 3 * ts, ty + 3 * ts, std::max(1, ts)); tx += 9 * ts; }
|
||||
cv.text(tx, ty, l.text, l.color, ts);
|
||||
if (!l.value.empty()) cv.text(bx + bw - 4 * ts - cv.text_width(l.value, ts), ty, l.value, rgba(255, 255, 255), ts);
|
||||
ty += lh;
|
||||
}
|
||||
}
|
||||
if (s.drag_on && (s.drag_icon || s.drag_bg >= 0)) {
|
||||
if (s.drag_bg >= 0 && s.drag_bg < 8 && as.itemBg[s.drag_bg].ok()) cv.icon(as.itemBg[s.drag_bg], s.drag_x - 10 * ui, s.drag_y - 10 * ui, ui);
|
||||
if (s.drag_icon && s.drag_icon->ok()) cv.blit_centered(s.drag_icon->px.data(), s.drag_icon->w, s.drag_icon->h, s.drag_x, s.drag_y, std::max(1, std::min(ui, (17 * ui) / std::max(s.drag_icon->w, 1))));
|
||||
}
|
||||
}
|
||||
// bars: a brown frame, a dark back, the fill in the colour of the game's colour strips
|
||||
const int right = cv.w - 45 * ui;
|
||||
auto bar = [&](float cy, int h, float frac, uint32_t fill) {
|
||||
const int top = (int)((cy - h * 0.5f) * ui), bx = right - 46 * ui, bw = 46 * ui;
|
||||
cv.rect(bx, top, bw, h * ui, rgba(121, 71, 56));
|
||||
cv.rect(bx + ui, top + ui, bw - 2 * ui, (h - 2) * ui, rgba(58, 36, 31, 235));
|
||||
cv.rect(bx + ui, top + ui, (int)((bw - 2 * ui) * std::clamp(frac, 0.0f, 1.0f)), (h - 2) * ui, fill);
|
||||
};
|
||||
auto small_icon = [&](const Icon& ic, float cy) { cv.icon(ic, right + 3 * ui, (int)((cy - 4.0f) * ui), ui); };
|
||||
const bool low = s.hp < 0.3f * s.maxhp;
|
||||
bar(25.7f, 6, s.hp / std::max(s.maxhp, 0.001f), low ? rgba(214, 90, 56) : rgba(135, 191, 28));
|
||||
small_icon(as.heart, 25.7f);
|
||||
cv.text(right + 14 * ui, (int)(25.7f * ui) - 3 * ts, std::to_string((int)std::lround(std::max(s.hp, 0.0f) * 25.0f)), white, ts);
|
||||
bar(35.7f, 5, s.energy, rgba(255, 170, 64));
|
||||
small_icon(as.jetpack, 35.7f);
|
||||
if (s.mana_max > 0) {
|
||||
bar(44.6f, 5, s.mana / s.mana_max, rgba(66, 168, 226));
|
||||
small_icon(as.mana, 44.6f);
|
||||
if (s.reload_left > 0) cv.rect(right - 46 * ui, (int)(47.6f * ui), std::min(46 * ui, s.reload_left * ui / 2), ui, rgba(255, 150, 60));
|
||||
else if (s.cooldown_left > 0) cv.rect(right - 46 * ui, (int)(47.6f * ui), std::min(46 * ui, s.cooldown_left * ui / 2), ui, rgba(255, 255, 255, 160));
|
||||
}
|
||||
small_icon(as.money, 52.5f);
|
||||
{ // the statuses: a column of icons under the bars (from the gold row down, at the bars' left edge); the mouse over one shows its name and description
|
||||
const int colX = right - 46 * ui;
|
||||
int sy2 = (int)(s.air < 0.999f ? 67.0f * ui : 60.0f * ui), hover = -1, hy = 0;
|
||||
for (size_t k = 0; k < s.statuses.size(); ++k) {
|
||||
const State::StatusIcon& si = s.statuses[k];
|
||||
const int w = si.icon && si.icon->ok() ? si.icon->w * ui : 8 * ui, h = si.icon && si.icon->ok() ? si.icon->h * ui : 8 * ui;
|
||||
if (si.icon && si.icon->ok()) cv.icon(*si.icon, colX + (10 * ui - w) / 2, sy2, ui);
|
||||
else cv.rect(colX + ui, sy2, 8 * ui, 8 * ui, rgba(200, 200, 200, 160));
|
||||
if (s.mouse_x >= colX - ui && s.mouse_x < colX + 11 * ui && s.mouse_y >= sy2 && s.mouse_y < sy2 + h) { hover = (int)k; hy = sy2; }
|
||||
sy2 += h + 2 * ui;
|
||||
}
|
||||
if (hover >= 0) {
|
||||
const State::StatusIcon& si = s.statuses[(size_t)hover];
|
||||
const int lh = 9 * ts, tw = std::max(cv.text_width(si.name, ts), si.desc.empty() ? 0 : cv.text_width(si.desc, ts)) + 8 * ts;
|
||||
const int bh = (si.desc.empty() ? 1 : 2) * lh + 6 * ts, bx = std::max(4, colX - 3 * ui - tw);
|
||||
cv.rect(bx, hy, tw, bh, rgba(22, 20, 20, 235)); cv.frame(bx, hy, tw, bh, rgba(196, 178, 140));
|
||||
cv.text(bx + 4 * ts, hy + 3 * ts, si.name, white, ts);
|
||||
if (!si.desc.empty()) cv.text(bx + 4 * ts, hy + 3 * ts + lh, si.desc, rgba(190, 190, 200), ts);
|
||||
}
|
||||
}
|
||||
cv.text(right + 14 * ui, (int)(52.5f * ui) - 3 * ts, std::to_string(s.money), white, ts);
|
||||
if (s.air < 0.999f) { // breath under liquid
|
||||
bar(61.0f, 5, s.air, s.air < 0.25f ? rgba(255, 80, 60) : rgba(120, 220, 255));
|
||||
cv.text(right + 14 * ui, (int)(61.0f * ui) - 3 * ts, "AIR", rgba(150, 230, 255), ts);
|
||||
}
|
||||
if (s.burning) cv.text(right - 46 * ui, (int)(66.0f * ui), "ON FIRE", rgba(255, 150, 40), ts);
|
||||
}
|
||||
// ---- brush palette ----
|
||||
if (!s.world || s.brush_mode) {
|
||||
const int x0 = 14, y0 = s.world ? 120 : 14;
|
||||
cv.text(x0, y0, s.world ? "BRUSH MODE (B to leave)" : "SANDBOX", rgba(255, 220, 120), 2);
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
const int x = x0 + i * 74, y = y0 + 24;
|
||||
cv.rect(x - 2, y - 2, 70, 34, i == s.brush_select ? rgba(255, 255, 255, 230) : rgba(0, 0, 0, 160));
|
||||
cv.rect(x, y, 66, 20, s.palette_colors ? s.palette_colors[i] : white);
|
||||
cv.rect(x, y + 20, 66, 10, rgba(0, 0, 0, 180));
|
||||
cv.text(x + 2, y + 21, std::to_string(i + 1) + " " + std::string(s.palette_names ? s.palette_names[i] : "").substr(0, 8), white, 1, false);
|
||||
}
|
||||
cv.text(x0, y0 + 64, "LMB paint RMB erase wheel = size " + std::to_string(s.brush_radius), grey, 1);
|
||||
}
|
||||
// ---- messages and the interaction hint ----
|
||||
{
|
||||
int y = 110;
|
||||
for (size_t i = 0; i < s.log_lines.size(); ++i) {
|
||||
const std::string& l = s.log_lines[i];
|
||||
cv.text(cv.w / 2 - cv.text_width(l, 2) / 2, 20 + (int)i * 24, l, rgba(255, 240, 160), 2);
|
||||
(void)y;
|
||||
}
|
||||
if (!s.hint.empty() && s.hint_x >= 0) cv.text(s.hint_x - cv.text_width(s.hint, 2) / 2, s.hint_y, s.hint, s.hint_color, 2);
|
||||
}
|
||||
// ---- wand in hand and projectiles ----
|
||||
if (s.hand_tex) cv.blit_rotated(s.hand_tex, s.hand_w, s.hand_h, s.hand_px, s.hand_py, s.hand_x, s.hand_y, s.hand_angle, 2, s.hand_mirror);
|
||||
for (const auto& p : s.projectiles) {
|
||||
if (p.tex && p.w > 0) cv.blit_centered(p.tex, p.w, p.h, p.sx, p.sy, 2);
|
||||
else cv.rect(p.sx - 3, p.sy - 3, 6, 6, p.color);
|
||||
}
|
||||
// ---- overlays ----
|
||||
for (const State::Layer& L : s.layers) {
|
||||
if (L.dim) cv.rect(0, 0, cv.w, cv.h, rgba(0, 0, 0, L.dim_alpha));
|
||||
for (const State::InvPanel& p : L.panels) {
|
||||
if (p.style == 3 && as.importantMsg.ok() && as.importantMsg.w == 72) { // the banner of the new-wand choice: 3piece_important_msg.png, its ends as they are, its middle stretched
|
||||
const Icon& im = as.importantMsg;
|
||||
const int pw = 24, ph = im.h;
|
||||
auto piece = [&](int k) { Icon c; c.w = pw; c.h = ph; c.px.resize((size_t)pw * ph); for (int j = 0; j < ph; ++j) for (int i = 0; i < pw; ++i) c.px[(size_t)j * pw + i] = im.px[(size_t)j * im.w + k * pw + i]; return c; };
|
||||
static Icon left, mid, right; static bool cut = false;
|
||||
if (!cut) { left = piece(0); mid = piece(1); right = piece(2); cut = true; }
|
||||
const int ew = pw * uiS, eh = p.h;
|
||||
cv.icon_fit(left, p.x, p.y, ew, eh);
|
||||
cv.icon_fit(mid, p.x + ew, p.y, std::max(1, p.w - 2 * ew), eh);
|
||||
cv.icon_fit(right, p.x + p.w - ew, p.y, ew, eh);
|
||||
} else if (p.style == 3) { // (without the art: an orange band with a golden frame and rounded ends)
|
||||
for (int j = 0; j < p.h; ++j) { const float t = (float)j / std::max(1, p.h - 1); const int r = 220 - (int)(60 * std::fabs(t - 0.4f)), gg = 112 - (int)(40 * std::fabs(t - 0.4f)); cv.rect(p.x, p.y + j, p.w, 1, rgba(r, gg, 24, 245)); }
|
||||
cv.frame(p.x, p.y, p.w, p.h, rgba(255, 196, 70)); cv.frame(p.x + uiS, p.y + uiS, p.w - 2 * uiS, p.h - 2 * uiS, rgba(120, 50, 10));
|
||||
cv.rect(p.x - 3 * uiS, p.y + p.h / 2 - 3 * uiS, 3 * uiS, 6 * uiS, rgba(255, 196, 70)); cv.rect(p.x + p.w, p.y + p.h / 2 - 3 * uiS, 3 * uiS, 6 * uiS, rgba(255, 196, 70));
|
||||
} else if (p.style == 2) { cv.rect(p.x, p.y, p.w, p.h, rgba(15, 14, 13, 225)); cv.frame(p.x, p.y, p.w, p.h, rgba(148, 128, 100)); cv.frame(p.x + 1, p.y + 1, p.w - 2, p.h - 2, rgba(148, 128, 100)); if (p.active) cv.frame(p.x - uiS, p.y - uiS, p.w + 2 * uiS, p.h + 2 * uiS, rgba(255, 160, 40)); } // (the information box: the game's fill and 2 px frame, sampled at 2560 x 1440)
|
||||
else { cv.rect(p.x, p.y, p.w, p.h, rgba(30, 22, 22, 235)); cv.frame(p.x, p.y, p.w, p.h, p.active ? rgba(255, 160, 40, 255) : rgba(200, 190, 170, 255)); }
|
||||
}
|
||||
for (const State::InvCell& c : L.cells) drawCell(c, uiS, tsS);
|
||||
for (const State::InvText& t : L.texts) cv.text(t.x, t.y, t.text, t.color, t.scale);
|
||||
for (const State::InvPanel& p : L.shades) cv.rect(p.x, p.y, p.w, p.h, rgba(0, 0, 0, 110));
|
||||
}
|
||||
// ---- messages ----
|
||||
if (s.death_timer > 0) {
|
||||
const std::string m = "YOU DIED";
|
||||
cv.rect(0, cv.h / 2 - 60, cv.w, 90, rgba(0, 0, 0, std::min(200, s.death_timer * 4)));
|
||||
cv.text(cv.w / 2 - cv.text_width(m, 6) / 2, cv.h / 2 - 40, m, rgba(220, 40, 40), 6);
|
||||
const std::string m2 = "respawning at the start";
|
||||
cv.text(cv.w / 2 - cv.text_width(m2, 2) / 2, cv.h / 2 + 6, m2, grey, 2);
|
||||
}
|
||||
if (s.paused) {
|
||||
cv.rect(0, 0, cv.w, cv.h, rgba(0, 0, 0, 90));
|
||||
const std::string m = "PAUSED";
|
||||
cv.text(cv.w / 2 - cv.text_width(m, 6) / 2, cv.h / 2 - 40, m, white, 6);
|
||||
}
|
||||
if (s.help) {
|
||||
const char* lines[] = {
|
||||
"A / D, arrows walk", "W, Up, Space jump (hold in the air to levitate)", "mouse aim, the camera leans toward it",
|
||||
"LMB fire the spark bolt", "E pick up / open the item next to you", "F kick (melee swing, throws crates and barrels)", "B brush mode: paint materials with the mouse (keys 1-8)",
|
||||
"5-8 flasks (LMB pour, RMB throw)", "Tab, I inventory: drag spells and wands (spells only in a workshop)", "RMB throw the item in the hand, Q drop it", "F2 free camera: WASD, [ ] speed, T teleport, G 10000 gold, no fog", "F8 debug mode: wand editing everywhere, K test wand, C spell panel (type to filter, click to spawn)",
|
||||
"F9 save the last 20 s (clips folder), F11 clip editor: cut and save a GIF", "F10 performance report of the last 20 s (perf folder)", "P pause", "F3 debug info, entity ids", "F4 lighting on / off", "F5 sky debug view", "F1 this help", "Esc quit",
|
||||
};
|
||||
cv.rect(300, 120, 680, 470, rgba(0, 0, 0, 215));
|
||||
cv.frame(300, 120, 680, 470, rgba(255, 255, 255, 160));
|
||||
cv.text(320, 134, "CONTROLS", rgba(255, 220, 120), 3);
|
||||
int y = 172;
|
||||
for (const char* l : lines) { cv.text(320, y, l, white, 2); y += 24; }
|
||||
} else if (s.has_player || !s.world) {
|
||||
cv.text(cv.w - 14 - cv.text_width("F1 help", 1), cv.h - 18, "F1 help", grey, 1);
|
||||
}
|
||||
if (s.debug || s.show_timing) { // speed measurement: GPU time of a whole frame, per simulation tick (upper bound), frame pacing
|
||||
const std::string t = "sim x" + std::to_string(s.sim_speed) + " GPU " + fmt("%.2f", s.gpu_ms) + " ms (" + fmt("%.3f", s.gpu_ms / std::max(1, s.sim_speed)) +
|
||||
"/tick) " + fmt("%.1f", s.frame_ms) + " ms/frame " + fmt("%.0f", s.fps) + " fps";
|
||||
cv.text(cv.w - 14 - cv.text_width(t, 2), s.debug ? 90 : 14, t, rgba(255, 220, 120), 2);
|
||||
}
|
||||
if (s.debug) {
|
||||
const std::string l1 = fmt("%.0f fps", s.fps) + " frame " + std::to_string(s.frame);
|
||||
const std::string l2 = "player " + fmt("%.0f", s.px) + ", " + fmt("%.0f", s.py) + " view " + std::to_string(s.view_x) + ", " + std::to_string(s.view_y);
|
||||
const std::string l3 = "entities " + std::to_string(s.entities) + " stored tiles " + std::to_string(s.stored_tiles) + " " + s.seed_text;
|
||||
int y = 14;
|
||||
for (const std::string& l : {l1, l2, l3}) {
|
||||
cv.text(cv.w - 14 - cv.text_width(l, 2), y, l, rgba(160, 255, 160), 2);
|
||||
y += 22;
|
||||
}
|
||||
}
|
||||
// ---- the mod GUI ----
|
||||
for (const State::GuiDraw& d : s.gui) {
|
||||
const float k = (float)s.gui_scale;
|
||||
const uint32_t a = (uint32_t)std::clamp(d.alpha * (float)(d.color >> 24), 0.0f, 255.0f);
|
||||
if (d.kind == 0) { cv.text((int)std::lround(d.x * k), (int)std::lround(d.y * k), d.text, (d.color & 0xFFFFFFu) | a << 24, std::max(1, (int)std::lround(k * d.scale)), true); continue; }
|
||||
if (!d.icon || !d.icon->ok()) continue;
|
||||
const Icon& ic = *d.icon;
|
||||
auto blit = [&](int sx0, int sy0, int sw, int sh, float dx, float dy, float dw, float dh) { // a part of the image stretched over the rectangle, alpha blended
|
||||
const int X0 = (int)std::lround(dx * k), Y0 = (int)std::lround(dy * k), X1 = (int)std::lround((dx + dw) * k), Y1 = (int)std::lround((dy + dh) * k);
|
||||
if (sw <= 0 || sh <= 0) return;
|
||||
for (int Y = std::max(0, Y0); Y < std::min(cv.h, Y1); ++Y) for (int X = std::max(0, X0); X < std::min(cv.w, X1); ++X) {
|
||||
const int ix = sx0 + std::min(sw - 1, (int)((X - X0) * (float)sw / std::max(1, X1 - X0))), iy = sy0 + std::min(sh - 1, (int)((Y - Y0) * (float)sh / std::max(1, Y1 - Y0)));
|
||||
const uint32_t c = ic.px[(size_t)iy * ic.w + ix];
|
||||
const uint32_t ca = (uint32_t)((c >> 24) * a / 255);
|
||||
if (ca < 8) continue;
|
||||
const uint32_t tint = d.color & 0xFFFFFFu;
|
||||
const uint32_t r = ((c >> 16) & 255) * ((tint >> 16) & 255) / 255, g2 = ((c >> 8) & 255) * ((tint >> 8) & 255) / 255, b = (c & 255) * (tint & 255) / 255;
|
||||
uint32_t& o = cv.px[(size_t)Y * cv.w + X];
|
||||
cv.mark(X, Y);
|
||||
const uint32_t oa = o >> 24;
|
||||
auto mix = [&](uint32_t s0, uint32_t d0) { return (s0 * ca + d0 * (255 - ca)) / 255; };
|
||||
o = std::max(oa, ca) << 24 | mix(r, (o >> 16) & 255) << 16 | mix(g2, (o >> 8) & 255) << 8 | mix(b, o & 255);
|
||||
}
|
||||
};
|
||||
if (d.kind == 1) { blit(0, 0, ic.w, ic.h, d.x, d.y, d.w, d.h); continue; }
|
||||
const int cw = std::max(1, ic.w / 3), ch = std::max(1, ic.h / 3), mw = ic.w - 2 * cw, mh = ic.h - 2 * ch; // nine pieces: the corners keep their size, the edges and the middle stretch
|
||||
const float W = std::max(d.w, (float)(2 * cw)), H = std::max(d.h, (float)(2 * ch));
|
||||
const float xs[3] = {d.x, d.x + cw, d.x + W - cw}, ws[3] = {(float)cw, W - 2 * cw, (float)cw}, ys[3] = {d.y, d.y + ch, d.y + H - ch}, hs3[3] = {(float)ch, H - 2 * ch, (float)ch};
|
||||
const int sxs[3] = {0, cw, cw + mw}, sws[3] = {cw, mw, cw}, sys[3] = {0, ch, ch + mh}, shs[3] = {ch, mh, ch};
|
||||
for (int j = 0; j < 3; ++j) for (int i = 0; i < 3; ++i) blit(sxs[i], sys[j], sws[i], shs[j], xs[i], ys[j], ws[i], hs3[j]);
|
||||
}
|
||||
// ---- crosshair (last: over the overlays and the menus) ----
|
||||
if (s.mouse_x >= 0 && !s.paused && !s.drag_on) { // (an item on the cursor replaces the crosshair)
|
||||
const uint32_t c = s.world && !s.brush_mode ? rgba(255, 255, 255, 230) : rgba(255, 220, 120, 230);
|
||||
cv.rect(s.mouse_x - 8, s.mouse_y - 1, 6, 2, c); cv.rect(s.mouse_x + 3, s.mouse_y - 1, 6, 2, c);
|
||||
cv.rect(s.mouse_x - 1, s.mouse_y - 8, 2, 6, c); cv.rect(s.mouse_x - 1, s.mouse_y + 3, 2, 6, c);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace hud
|
||||
@@ -0,0 +1,216 @@
|
||||
#pragma once
|
||||
// The inventory of the player: the quick inventory (4 wand slots, 4 item slots), the full inventory (16 spell slots) and the spell slots of the wands,
|
||||
// with the rules of the original engine for what may be dropped where (recovered from noita.exe: FUN_00b5ee40 and the predicates it calls, see
|
||||
// docs/INVENTORY_UI.md). Items are runtime entities; where they are is kept in `ItemComponent.inventory_slot.x / .y` like in the game, the owner of a spell is
|
||||
// its parent (the wand); the gun reads a wand's spells by slot.
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "entity/runtime.h"
|
||||
#include "gun/gun.h"
|
||||
|
||||
namespace ng {
|
||||
|
||||
class Inventory {
|
||||
public:
|
||||
static constexpr int kWands = 4, kItems = 4, kFull = 16;
|
||||
enum Kind { None = 0, QuickWand, QuickItem, Full, WandSpell };
|
||||
struct Loc {
|
||||
Kind kind = None;
|
||||
int wand = 0; // WandSpell: the wand entity
|
||||
int index = 0; // slot number inside the container
|
||||
bool operator==(const Loc& o) const { return kind == o.kind && wand == o.wand && index == o.index; }
|
||||
};
|
||||
|
||||
Inventory(EntityRuntime& rt, GunRuntime& gun) : rt_(rt), gun_(gun) {}
|
||||
|
||||
std::array<int, kWands> wands{}; // entity ids, 0 = empty slot
|
||||
std::array<int, kItems> items{};
|
||||
std::array<int, kFull> full{};
|
||||
bool editing = false; // the wands may be edited (inside a workshop, or the perk EDIT_WANDS_EVERYWHERE): DAT_0122250a of the engine
|
||||
|
||||
// ---- predicates of the engine (FUN_00b5db10 and friends) ----
|
||||
bool has(int e, const char* comp) { return e && !rt_.components(e, comp, "", true).empty(); }
|
||||
bool is_wand(int e) { return has(e, "AbilityComponent") && attr(e, "AbilityComponent", "use_gun_script") == "1"; } // FUN_00b5db10: AbilityComponent.use_gun_script
|
||||
bool is_spell(int e) { return has(e, "ItemActionComponent"); } // FUN_00b5dbf0
|
||||
bool is_pure_spell(int e) { return is_spell(e) && !has(e, "AbilityComponent"); } // FUN_00b5dd60
|
||||
bool is_flask(int e) { return has(e, "MaterialInventoryComponent"); } // FUN_00b5e0d0
|
||||
std::string preferred(int e) { return attr(e, "ItemComponent", "preferred_inventory"); } // ItemComponent.preferred_inventory: DEFAULT / QUICK / FULL
|
||||
|
||||
int capacity(int wand) { // FUN_00b5d7d0: the slots of a wand = its deck capacity
|
||||
return (int)std::atof(attr(wand, "AbilityComponent", "gun_config.deck_capacity").c_str());
|
||||
}
|
||||
int slot_x(int item) { return (int)std::atof(attr(item, "ItemComponent", "inventory_slot.x").c_str()); }
|
||||
void set_slot(int item, int x, int y = 0) {
|
||||
for (int cid : rt_.components(item, "ItemComponent", "", true)) {
|
||||
rt_.comp(cid)->attrs["inventory_slot.x"] = std::to_string(x);
|
||||
rt_.comp(cid)->attrs["inventory_slot.y"] = std::to_string(y);
|
||||
}
|
||||
}
|
||||
|
||||
// The spells of a wand by slot (0 = empty), `capacity` entries; the always-cast ones are not in slots.
|
||||
std::vector<int> spells(int wand) {
|
||||
std::vector<int> r(std::max(capacity(wand), 0), 0);
|
||||
for (int k : rt_.children(wand)) {
|
||||
if (!is_spell(k) || permanent(k)) continue;
|
||||
const int x = slot_x(k);
|
||||
if (x >= 0 && x < (int)r.size() && !r[x]) r[x] = k;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
std::vector<int> permanent_spells(int wand) {
|
||||
std::vector<int> r;
|
||||
for (int k : rt_.children(wand)) if (is_spell(k) && permanent(k)) r.push_back(k);
|
||||
return r;
|
||||
}
|
||||
bool permanent(int spell) { // (AddGunActionPermanent sets the flag on the ItemComponent, the data files on the ItemActionComponent)
|
||||
for (int cid : rt_.components(spell, "ItemActionComponent", "", true)) if (rt_.comp(cid)->attrs["permanently_attached"] == "1") return true;
|
||||
for (int cid : rt_.components(spell, "ItemComponent", "", true)) if (rt_.comp(cid)->attrs["permanently_attached"] == "1") return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
Loc where(int item) {
|
||||
for (int i = 0; i < kWands; ++i) if (wands[i] == item) return {QuickWand, 0, i};
|
||||
for (int i = 0; i < kItems; ++i) if (items[i] == item) return {QuickItem, 0, i};
|
||||
for (int i = 0; i < kFull; ++i) if (full[i] == item) return {Full, 0, i};
|
||||
if (RtEntity* e = rt_.ent(item)) if (e->parent && isWandLike(e->parent)) {
|
||||
const int x = slot_x(item);
|
||||
return {WandSpell, e->parent, x};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
int at(const Loc& l) {
|
||||
switch (l.kind) {
|
||||
case QuickWand: return l.index >= 0 && l.index < kWands ? wands[l.index] : 0;
|
||||
case QuickItem: return l.index >= 0 && l.index < kItems ? items[l.index] : 0;
|
||||
case Full: return l.index >= 0 && l.index < kFull ? full[l.index] : 0;
|
||||
case WandSpell: { auto s = spells(l.wand); return l.index >= 0 && l.index < (int)s.size() ? s[l.index] : 0; }
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the rules: may `item` be dropped into the container of `target`? (FUN_00b5ee40) ----
|
||||
bool can_drop(int item, const Loc& target) {
|
||||
if (!item || target.kind == None) return false;
|
||||
const bool to_quick = target.kind == QuickWand || target.kind == QuickItem, to_full = target.kind == Full, to_wand = target.kind == WandSpell;
|
||||
if (is_wand(item) && !to_quick) return false;
|
||||
if (target.kind == QuickWand && !is_wand(item)) return false; // the wands row takes wands, the items row everything else of the quick inventory
|
||||
if (target.kind == QuickItem && is_wand(item)) return false; // wands only live in the quick inventory
|
||||
const Loc cur = where(item);
|
||||
if (same_container(cur, target)) return true; // moving inside a container is always allowed
|
||||
if (to_wand && target.wand == item) return false;
|
||||
if (to_wand && !editing) return false; // a wand takes spells only while the wands may be edited
|
||||
if (preferred(item) == "FULL" && to_full && !is_spell(item)) return true; // (the sampo and other items that live in the full inventory)
|
||||
if (to_full && !editing) return false; // spells only enter the full inventory while editing
|
||||
if (is_spell(item) && to_wand) return !is_inside(item, target.wand); // a wand that is not yet holding it (the container is a wand here)
|
||||
if (is_spell(item) && to_quick) return false; // spells never go into the quick inventory
|
||||
if (is_flask(item) && to_wand) return false;
|
||||
if (!is_spell(item)) { if (to_full) return false; return to_quick; } // other items: the quick inventory only (unless they prefer the full one)
|
||||
return to_full; // spells: the full inventory
|
||||
}
|
||||
|
||||
// Free place for an item that is picked up (the engine's pick-up goes to the preferred container of the item): returns the location or {}.
|
||||
Loc free_place(int item) {
|
||||
if (is_spell(item) || preferred(item) == "FULL") { for (int i = 0; i < kFull; ++i) if (!full[i]) return {Full, 0, i}; return {}; }
|
||||
if (is_wand(item)) { for (int i = 0; i < kWands; ++i) if (!wands[i]) return {QuickWand, 0, i}; return {}; }
|
||||
for (int i = 0; i < kItems; ++i) if (!items[i]) return {QuickItem, 0, i};
|
||||
return {};
|
||||
}
|
||||
bool can_pick(int item) { return free_place(item).kind != None; }
|
||||
|
||||
// The item enters the inventory at its preferred place (picking it up never needs the editing rights).
|
||||
bool add(int item) {
|
||||
const Loc l = free_place(item);
|
||||
if (l.kind == None) return false;
|
||||
put(item, l);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Moves an item to the slot `target`: into an empty slot when the rules allow, onto an occupied slot as a swap when both may go to the other's container.
|
||||
// Returns true when something changed.
|
||||
bool drop_on(int item, const Loc& target) {
|
||||
const Loc from = where(item);
|
||||
if (from.kind == None || target.kind == None || from == target) return false;
|
||||
const int other = at(target);
|
||||
if (!can_drop(item, target)) return false;
|
||||
if (!other) {
|
||||
take(item);
|
||||
put(item, target);
|
||||
return true;
|
||||
}
|
||||
if (other == item) return false;
|
||||
if (!can_drop(other, from)) return false; // a swap: the other one must be allowed to go where the item came from
|
||||
take(item); take(other);
|
||||
put(item, target);
|
||||
put(other, from);
|
||||
return true;
|
||||
}
|
||||
|
||||
// The item leaves the inventory (thrown, dropped, consumed).
|
||||
void remove(int item) {
|
||||
const Loc l = where(item);
|
||||
take(item);
|
||||
(void)l;
|
||||
}
|
||||
|
||||
// First free spell slot of a wand (-1: full).
|
||||
int free_spell_slot(int wand) {
|
||||
auto s = spells(wand);
|
||||
for (int i = 0; i < (int)s.size(); ++i) if (!s[i]) return i;
|
||||
return -1;
|
||||
}
|
||||
// Shift-click: a spell of the full inventory goes to the first free slot of `wand` (editing only), a spell of a wand to the first free full slot.
|
||||
bool quick_move(int item, int wand) {
|
||||
const Loc from = where(item);
|
||||
if (from.kind == Full && wand) { const int s = free_spell_slot(wand); return s >= 0 && drop_on(item, {WandSpell, wand, s}); }
|
||||
if (from.kind == WandSpell) { for (int i = 0; i < kFull; ++i) if (!full[i]) return drop_on(item, {Full, 0, i}); }
|
||||
return false;
|
||||
}
|
||||
|
||||
// after any change of the spells of a wand the gun has to read them again
|
||||
std::function<void(int wand)> on_wand_changed;
|
||||
|
||||
private:
|
||||
std::string attr(int e, const char* comp, const char* key) {
|
||||
for (int cid : rt_.components(e, comp, "", true)) { RtComponent* c = rt_.comp(cid); auto it = c->attrs.find(key); if (it != c->attrs.end()) return it->second; }
|
||||
return "";
|
||||
}
|
||||
bool isWandLike(int e) { return is_wand(e); }
|
||||
bool is_inside(int item, int wand) { RtEntity* e = rt_.ent(item); return e && e->parent == wand; }
|
||||
static bool same_container(const Loc& a, const Loc& b) {
|
||||
if (a.kind == None || b.kind == None) return false;
|
||||
const bool qa = a.kind == QuickWand || a.kind == QuickItem, qb = b.kind == QuickWand || b.kind == QuickItem;
|
||||
if (qa && qb) return true;
|
||||
if (a.kind == Full && b.kind == Full) return true;
|
||||
if (a.kind == WandSpell && b.kind == WandSpell) return a.wand == b.wand;
|
||||
return false;
|
||||
}
|
||||
void take(int item) {
|
||||
for (auto& v : wands) if (v == item) v = 0;
|
||||
for (auto& v : items) if (v == item) v = 0;
|
||||
for (auto& v : full) if (v == item) v = 0;
|
||||
if (RtEntity* e = rt_.ent(item)) if (e->parent && is_wand(e->parent) && is_spell(item)) {
|
||||
const int w = e->parent;
|
||||
e->parent = rt_.player();
|
||||
if (on_wand_changed) on_wand_changed(w);
|
||||
}
|
||||
}
|
||||
void put(int item, const Loc& l) {
|
||||
RtEntity* e = rt_.ent(item);
|
||||
switch (l.kind) {
|
||||
case QuickWand: wands[l.index] = item; if (e) e->parent = rt_.player(); set_slot(item, l.index); break;
|
||||
case QuickItem: items[l.index] = item; if (e) e->parent = rt_.player(); set_slot(item, kWands + l.index); break;
|
||||
case Full: full[l.index] = item; if (e) e->parent = rt_.player(); set_slot(item, l.index); break;
|
||||
case WandSpell: if (e) e->parent = l.wand; set_slot(item, l.index); if (on_wand_changed) on_wand_changed(l.wand); break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
EntityRuntime& rt_;
|
||||
GunRuntime& gun_;
|
||||
};
|
||||
|
||||
} // namespace ng
|
||||
+13847
File diff suppressed because it is too large.
Load diff
+151
@@ -0,0 +1,151 @@
|
||||
#pragma once
|
||||
// Always-on frame recorder for slowdowns: the time of every frame split into named CPU sections (cheap clock marks in the frame loop), the GPU
|
||||
// passes (timestamps), and counts of what the world holds; the last 1200 frames are kept. F10 (or NG_PERF_AUTO=ms: any frame slower than that,
|
||||
// at most every 10 s) writes perf/perf_<time>.txt (summary, averages, the slowest frames in full) and perf_<time>.csv (every frame).
|
||||
#include <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ng {
|
||||
|
||||
class PerfRecorder {
|
||||
public:
|
||||
static constexpr int kMaxSections = 32, kGpu = 20, kCounts = 12;
|
||||
struct Frame {
|
||||
int frame = 0;
|
||||
float total = 0, wait = 0, gpu = 0; // whole frame (wall time from one frame to the next), waiting for the GPU / the swap chain, the GPU's own time
|
||||
float sect[kMaxSections] = {};
|
||||
float gpuPass[kGpu] = {};
|
||||
int count[kCounts] = {};
|
||||
};
|
||||
|
||||
PerfRecorder() : ring_(1200) {}
|
||||
|
||||
// a section name per id (registered once, in order of first use)
|
||||
int section(const char* name) {
|
||||
for (int i = 0; i < (int)names_.size(); ++i) if (names_[i] == name) return i;
|
||||
if ((int)names_.size() >= kMaxSections) return kMaxSections - 1;
|
||||
names_.push_back(name);
|
||||
return (int)names_.size() - 1;
|
||||
}
|
||||
void set_count_names(std::vector<std::string> n) { countNames_ = std::move(n); }
|
||||
void set_gpu_names(std::vector<std::string> n) { gpuNames_ = std::move(n); }
|
||||
|
||||
// the start of a frame: the previous one is closed
|
||||
void begin(int frame) {
|
||||
const auto now = clk::now();
|
||||
if (open_) {
|
||||
cur_.total = ms(frameT_, now);
|
||||
ring_[head_ % ring_.size()] = cur_;
|
||||
++head_;
|
||||
if (autoMs_ > 0 && cur_.total > autoMs_ && std::chrono::duration<double>(now - lastAuto_).count() > 10.0) { pendingAuto_ = true; lastAuto_ = now; }
|
||||
}
|
||||
cur_ = Frame();
|
||||
cur_.frame = frame;
|
||||
frameT_ = now; markT_ = now; curSect_ = -1; open_ = true;
|
||||
}
|
||||
// from here on the time goes to section `id` (until the next mark)
|
||||
void mark(int id) {
|
||||
const auto now = clk::now();
|
||||
if (curSect_ >= 0 && curSect_ < kMaxSections) cur_.sect[curSect_] += ms(markT_, now);
|
||||
curSect_ = id; markT_ = now;
|
||||
}
|
||||
void wait_ms(float w) { cur_.wait += w; }
|
||||
void gpu_ms(float g) { cur_.gpu = g; }
|
||||
void gpu_pass(int k, float v) { if (k >= 0 && k < kGpu) cur_.gpuPass[k] = v; }
|
||||
void count(int k, int v) { if (k >= 0 && k < kCounts) cur_.count[k] = v; }
|
||||
void set_auto(double msThreshold) { autoMs_ = msThreshold; }
|
||||
bool take_auto() { const bool a = pendingAuto_; pendingAuto_ = false; return a; }
|
||||
|
||||
// the report: returns the path of the text file
|
||||
std::string dump(const std::string& dir, const std::string& context, const char* why) {
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
fs::create_directories(dir, ec);
|
||||
std::time_t t = std::time(nullptr);
|
||||
std::tm tm{};
|
||||
localtime_s(&tm, &t);
|
||||
char stamp[32];
|
||||
std::strftime(stamp, sizeof stamp, "%Y%m%d_%H%M%S", &tm);
|
||||
const std::string base = (fs::path(dir) / ("perf_" + std::string(stamp))).string();
|
||||
std::vector<Frame> fr;
|
||||
const size_t n = std::min(head_, ring_.size());
|
||||
for (size_t k = head_ - n; k < head_; ++k) fr.push_back(ring_[k % ring_.size()]);
|
||||
if (FILE* f = std::fopen((base + ".csv").c_str(), "wb")) {
|
||||
std::fprintf(f, "frame,total_ms,wait_ms,gpu_ms");
|
||||
for (const auto& s : names_) std::fprintf(f, ",\"%s\"", s.c_str()); // (quoted: some names hold commas)
|
||||
for (const auto& s : gpuNames_) std::fprintf(f, ",\"gpu %s\"", s.c_str());
|
||||
for (const auto& s : countNames_) std::fprintf(f, ",\"%s\"", s.c_str());
|
||||
std::fprintf(f, "\r\n");
|
||||
for (const Frame& x : fr) {
|
||||
std::fprintf(f, "%d,%.3f,%.3f,%.3f", x.frame, x.total, x.wait, x.gpu);
|
||||
for (size_t s = 0; s < names_.size(); ++s) std::fprintf(f, ",%.3f", x.sect[s]);
|
||||
for (size_t s = 0; s < gpuNames_.size(); ++s) std::fprintf(f, ",%.3f", x.gpuPass[s]);
|
||||
for (size_t s = 0; s < countNames_.size(); ++s) std::fprintf(f, ",%d", x.count[s]);
|
||||
std::fprintf(f, "\r\n");
|
||||
}
|
||||
std::fclose(f);
|
||||
}
|
||||
FILE* f = std::fopen((base + ".txt").c_str(), "wb");
|
||||
if (!f) return "";
|
||||
auto line = [&](const Frame& x) {
|
||||
std::fprintf(f, "frame %6d %6.1f ms (waiting for the GPU %.1f, GPU %.1f)\r\n", x.frame, x.total, x.wait, x.gpu);
|
||||
std::vector<std::pair<float, std::string>> v;
|
||||
for (size_t s = 0; s < names_.size(); ++s) if (x.sect[s] >= 0.05f) v.push_back({x.sect[s], names_[s]});
|
||||
std::sort(v.rbegin(), v.rend());
|
||||
std::fprintf(f, " CPU:");
|
||||
for (auto& [m, s] : v) std::fprintf(f, " %s %.2f", s.c_str(), m);
|
||||
std::fprintf(f, "\r\n GPU:");
|
||||
for (size_t s = 0; s < gpuNames_.size(); ++s) if (x.gpuPass[s] >= 0.05f) std::fprintf(f, " %s %.2f", gpuNames_[s].c_str(), x.gpuPass[s]);
|
||||
std::fprintf(f, "\r\n counts:");
|
||||
for (size_t s = 0; s < countNames_.size(); ++s) std::fprintf(f, " %s %d", countNames_[s].c_str(), x.count[s]);
|
||||
std::fprintf(f, "\r\n");
|
||||
};
|
||||
std::fprintf(f, "noita_gpu performance report (%s), %s\r\n%s\r\n\r\n", why, stamp, context.c_str());
|
||||
if (!fr.empty()) {
|
||||
double avg = 0, worst = 0;
|
||||
Frame mean;
|
||||
for (const Frame& x : fr) {
|
||||
avg += x.total; worst = std::max(worst, (double)x.total);
|
||||
mean.wait += x.wait; mean.gpu += x.gpu;
|
||||
for (int s = 0; s < kMaxSections; ++s) mean.sect[s] += x.sect[s];
|
||||
for (int s = 0; s < kGpu; ++s) mean.gpuPass[s] += x.gpuPass[s];
|
||||
}
|
||||
const float k = 1.0f / (float)fr.size();
|
||||
mean.total = (float)(avg * k); mean.wait *= k; mean.gpu *= k;
|
||||
for (float& s : mean.sect) s *= k;
|
||||
for (float& s : mean.gpuPass) s *= k;
|
||||
mean.frame = -1;
|
||||
for (int s = 0; s < kCounts; ++s) mean.count[s] = fr.back().count[s];
|
||||
std::fprintf(f, "%zu frames (%.1f s of frames): average %.1f ms, worst %.1f ms\r\n\r\nAVERAGE (counts: the last frame)\r\n", fr.size(), avg / 1000.0, avg * k, worst);
|
||||
line(mean);
|
||||
std::vector<const Frame*> sorted;
|
||||
for (const Frame& x : fr) sorted.push_back(&x);
|
||||
std::sort(sorted.begin(), sorted.end(), [](const Frame* a, const Frame* b) { return a->total > b->total; });
|
||||
std::fprintf(f, "\r\nTHE 25 SLOWEST FRAMES\r\n");
|
||||
for (size_t q = 0; q < std::min<size_t>(25, sorted.size()); ++q) line(*sorted[q]);
|
||||
std::fprintf(f, "\r\nTHE LAST 60 FRAMES\r\n");
|
||||
for (size_t q = fr.size() > 60 ? fr.size() - 60 : 0; q < fr.size(); ++q) line(fr[q]);
|
||||
}
|
||||
std::fclose(f);
|
||||
return base + ".txt";
|
||||
}
|
||||
|
||||
private:
|
||||
using clk = std::chrono::steady_clock;
|
||||
static float ms(clk::time_point a, clk::time_point b) { return std::chrono::duration<float, std::milli>(b - a).count(); }
|
||||
std::vector<Frame> ring_;
|
||||
size_t head_ = 0;
|
||||
Frame cur_;
|
||||
bool open_ = false, pendingAuto_ = false;
|
||||
int curSect_ = -1;
|
||||
double autoMs_ = 0;
|
||||
clk::time_point frameT_ = clk::now(), markT_ = clk::now(), lastAuto_ = clk::now() - std::chrono::hours(1);
|
||||
std::vector<std::string> names_, gpuNames_, countNames_;
|
||||
};
|
||||
|
||||
} // namespace ng
|
||||
Reference in new issue
Block a user