Fry3DX 0.4: an easy 3D modeller

Shapes with live settings, shape editing, knife and hole tools, drawing
shapes onto other objects, combining objects (add / subtract / overlap),
copies, thickness, painting with stickers and layers, a Layout tab, a
Print tab with checks, packed .fry files with autosave, export to GLB,
glTF, FBX, OBJ, 3MF, STL and PLY, folders in Layers, its own crash
screen, and a website for GitHub Pages in docs/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NMSrd5oe1GpUD7E6d825KP
This commit is contained in:
Nexoniarz 2026-10-19 21:18:35 +02:00
commit f3e8eca87b
135 changed files with 23423 additions and 0 deletions

309
engine/core/console.cpp Normal file
View file

@ -0,0 +1,309 @@
// The developer console, Source-style: it drops down from the top; as you type, a list under
// the input shows the commands and cvars that match (cvars with their values) and, once a
// command is typed, what its argument can be (map names for "map", files for "exec", ...).
// Arrows walk the list (writing the pick into the input, like Source), Tab completes, Enter
// runs. History survives restarts.
#include "core/console.h"
#include "core/cvar.h"
#include "core/log.h"
#include <imgui.h>
#include <algorithm>
#include <cstdio>
#include <fstream>
#include <string>
#include <vector>
namespace keel::console {
namespace {
bool g_open = false;
bool g_focus = false;
float g_slide = 0.0f; // 0 = up, 1 = down
char g_input[512] = {};
std::vector<std::string> g_history;
int g_historyPos = -1;
std::string g_historyFile;
struct Suggestion {
std::string text; // the whole input it stands for
std::string label; // what's shown: the name or argument
std::string value; // cvars: their value
std::string help;
bool pick = true; // false: an info row (a cvar's description)
};
std::vector<Suggestion> g_list;
int g_sel = -1;
std::string g_typed; // what the user typed (the list is built from it, not from picks)
std::string g_pending; // replace the input with this on the next callback (a click)
bool g_hasPending = false;
constexpr size_t kMaxSuggestions = 14;
bool containsNoCase(std::string_view hay, std::string_view needle) {
auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); };
return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end();
}
void describe(Suggestion& s, const std::string& name) {
if (Cmd* c = Cmd::find(name)) s.help = c->help();
else if (Cvar* v = Cvar::find(name)) s.value = v->str(), s.help = v->help();
else
for (const auto& [n, text] : aliases())
if (n == name) s.help = "alias: " + text;
}
void buildList() {
g_list.clear();
g_sel = -1;
const std::string& in = g_typed;
// Only the last command of a "a; b; c" line.
const size_t semi = in.rfind(';');
std::string head = semi == std::string::npos ? "" : in.substr(0, semi + 1) + " ";
std::string seg = semi == std::string::npos ? in : in.substr(semi + 1);
seg.erase(0, seg.find_first_not_of(' '));
if (semi != std::string::npos) head = in.substr(0, in.size() - seg.size());
if (seg.empty()) return;
const size_t space = seg.find(' ');
if (space == std::string::npos) {
// A name: those starting with it first, then those containing it.
const std::vector<std::string> names = allNames();
for (int pass = 0; pass < 2 && g_list.size() < kMaxSuggestions; ++pass)
for (const std::string& n : names) {
const bool prefix = n.starts_with(seg);
if (pass == 0 ? !prefix : (prefix || !containsNoCase(n, seg))) continue;
Suggestion s{head + n + " ", n};
describe(s, n);
g_list.push_back(std::move(s));
if (g_list.size() >= kMaxSuggestions) break;
}
return;
}
// An argument: the command's own suggestions, or what a cvar is.
const std::string name = seg.substr(0, space);
const size_t lastSpace = seg.rfind(' ');
const std::string partial = seg.substr(lastSpace + 1);
const std::string before = head + seg.substr(0, lastSpace + 1);
if (Cmd* c = Cmd::find(name); c && c->completer()) {
const size_t arg = tokenize(seg.substr(0, lastSpace)).size(); // the command is token 0
for (const std::string& v : c->completer()(partial, arg)) {
Suggestion s{before + v + " ", v};
if (name == "help" || name == "reset" || name == "toggle" || name == "set" || name == "incrementvar") describe(s, v);
g_list.push_back(std::move(s));
if (g_list.size() >= kMaxSuggestions) break;
}
} else if (Cvar* v = Cvar::find(name)) {
Suggestion s{"", name, v->str(), std::string(v->help()) + " (default \"" + v->defaultValue() + "\")"};
s.pick = false;
g_list.push_back(std::move(s));
} else if (Cmd* c2 = Cmd::find(name)) {
Suggestion s{"", name, "", c2->help()};
s.pick = false;
g_list.push_back(std::move(s));
}
}
void setInput(ImGuiInputTextCallbackData* data, const std::string& text) {
data->DeleteChars(0, data->BufTextLen);
data->InsertChars(0, text.c_str());
}
int inputCallback(ImGuiInputTextCallbackData* data) {
switch (data->EventFlag) {
case ImGuiInputTextFlags_CallbackAlways:
if (g_hasPending) {
setInput(data, g_pending);
g_hasPending = false;
}
break;
case ImGuiInputTextFlags_CallbackEdit:
g_typed.assign(data->Buf, size_t(data->BufTextLen));
g_historyPos = -1;
buildList();
break;
case ImGuiInputTextFlags_CallbackHistory: {
const bool up = data->EventKey == ImGuiKey_UpArrow;
int picks = 0;
for (const auto& s : g_list) picks += s.pick;
if (picks > 0 && !g_typed.empty()) { // walk the suggestions
int i = g_sel;
do i = up ? (i <= 0 ? int(g_list.size()) - 1 : i - 1) : (i + 1) % int(g_list.size());
while (!g_list[size_t(i)].pick);
g_sel = i;
setInput(data, g_list[size_t(i)].text);
} else if (!g_history.empty()) { // or the history
if (up) g_historyPos = g_historyPos < 0 ? int(g_history.size()) - 1 : std::max(0, g_historyPos - 1);
else if (g_historyPos >= 0 && ++g_historyPos >= int(g_history.size())) g_historyPos = -1;
setInput(data, g_historyPos >= 0 ? g_history[size_t(g_historyPos)] : "");
}
break;
}
case ImGuiInputTextFlags_CallbackCompletion: {
// Tab: the picked suggestion, else the first, else the longest common start.
std::vector<const Suggestion*> picks;
for (const auto& s : g_list)
if (s.pick) picks.push_back(&s);
if (picks.empty()) break;
const Suggestion* s = g_sel >= 0 && g_list[size_t(g_sel)].pick ? &g_list[size_t(g_sel)] : picks.front();
setInput(data, s->text);
g_typed = s->text;
buildList();
break;
}
default:
break;
}
return 0;
}
void remember(const std::string& line) {
if (g_history.empty() || g_history.back() != line) g_history.push_back(line);
if (g_history.size() > 200) g_history.erase(g_history.begin());
if (g_historyFile.empty()) return;
std::ofstream out(g_historyFile);
for (const auto& h : g_history) out << h << '\n';
}
} // namespace
namespace {
Cmd cmd_toggleconsole("toggleconsole", [](const Args&) { toggle(); }, "Open or close the console");
Cmd cmd_con_input("con_input", [](const Args& a) {
std::string text;
for (size_t i = 1; i < a.size(); ++i) text += (i > 1 ? " " : "") + a[i];
if (a.size() > 1 && a.back().empty()) text += " ";
std::snprintf(g_input, sizeof g_input, "%s", text.c_str());
g_pending = text; // the input box keeps its own copy while focused
g_hasPending = true;
g_typed = text;
buildList();
if (!g_open) toggle();
}, "con_input <text>: open the console with this typed (scripts, tests)");
} // namespace
void toggle() {
g_open = !g_open;
g_focus = g_open;
}
bool isOpen() { return g_open; }
void setHistoryFile(const std::string& path) {
g_historyFile = path;
g_history.clear();
std::ifstream in(path);
for (std::string line; std::getline(in, line);)
if (!line.empty()) g_history.push_back(line);
}
void draw() {
const float dt = ImGui::GetIO().DeltaTime;
g_slide = std::clamp(g_slide + (g_open ? dt : -dt) * 7.0f, 0.0f, 1.0f);
if (g_slide <= 0.0f) return;
const float k = 1.0f - (1.0f - g_slide) * (1.0f - g_slide); // ease out
const ImGuiViewport* vp = ImGui::GetMainViewport();
const float fullH = vp->WorkSize.y * 0.46f;
const ImU32 accent = IM_COL32(226, 54, 54, 255);
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y - fullH * (1.0f - k)));
ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, fullH));
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(14, 13, 15, 238));
ImGui::PushStyleColor(ImGuiCol_FrameBg, IM_COL32(255, 255, 255, 10));
ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(14, 10));
ImGui::Begin("Console", nullptr, ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings);
const ImVec2 wp = ImGui::GetWindowPos(), ws = ImGui::GetWindowSize();
ImGui::GetWindowDrawList()->AddRectFilled(ImVec2(wp.x, wp.y + ws.y - 2), ImVec2(wp.x + ws.x, wp.y + ws.y), accent);
const float footer = ImGui::GetFrameHeightWithSpacing() + 4;
ImGui::BeginChild("lines", ImVec2(0, -footer));
for (const log::Line& line : log::history()) {
const bool echo = line.text.starts_with("] ");
const ImVec4 color = line.level == log::Level::Error ? ImVec4(1, .42f, .42f, 1)
: line.level == log::Level::Warn ? ImVec4(1, .78f, .35f, 1)
: echo ? ImVec4(.95f, .55f, .5f, 1)
: ImVec4(.86f, .85f, .83f, 1);
ImGui::TextColored(color, "%s", line.text.c_str());
}
if (ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f);
ImGui::EndChild();
// The input, with a prompt.
ImGui::TextColored(ImVec4(.89f, .21f, .21f, 1), ">");
ImGui::SameLine();
ImGui::SetNextItemWidth(-1);
if (g_focus) {
ImGui::SetKeyboardFocusHere();
g_focus = false;
}
const ImVec2 inputPos = ImGui::GetCursorScreenPos();
const auto flags = ImGuiInputTextFlags_EnterReturnsTrue | ImGuiInputTextFlags_CallbackHistory | ImGuiInputTextFlags_CallbackCompletion |
ImGuiInputTextFlags_CallbackEdit | ImGuiInputTextFlags_CallbackAlways;
if (ImGui::InputText("##input", g_input, sizeof g_input, flags, inputCallback)) {
std::string line = g_input;
while (!line.empty() && line.back() == ' ') line.pop_back();
if (!line.empty()) {
log::info("] {}", line);
remember(line);
execute(line);
}
g_input[0] = 0;
g_typed.clear();
g_list.clear();
g_sel = -1;
g_historyPos = -1;
ImGui::SetKeyboardFocusHere(-1);
}
if (ImGui::IsItemActive() && ImGui::IsKeyPressed(ImGuiKey_L) && ImGui::GetIO().KeyCtrl) log::clearHistory();
const float inputH = ImGui::GetItemRectSize().y;
ImGui::End();
ImGui::PopStyleVar(2);
ImGui::PopStyleColor(2);
// The suggestions, under the console like Source's.
if (g_list.empty() || g_slide < 1.0f || !g_open) return;
ImGui::SetNextWindowPos(ImVec2(inputPos.x, inputPos.y + inputH + 6));
ImGui::SetNextWindowBgAlpha(0.96f);
ImGui::PushStyleColor(ImGuiCol_WindowBg, IM_COL32(22, 20, 23, 245));
ImGui::PushStyleColor(ImGuiCol_Border, IM_COL32(226, 54, 54, 120));
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 6));
ImGui::Begin("##suggestions", nullptr,
ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav);
float nameW = 0, valueW = 0;
for (const auto& s : g_list) {
nameW = std::max(nameW, ImGui::CalcTextSize(s.label.c_str()).x);
valueW = std::max(valueW, s.value.empty() ? 0.0f : ImGui::CalcTextSize(("\"" + s.value + "\"").c_str()).x);
}
const float maxW = vp->WorkSize.x - (inputPos.x - vp->WorkPos.x) - 40;
for (int i = 0; i < int(g_list.size()); ++i) {
const Suggestion& s = g_list[size_t(i)];
ImGui::PushID(i);
const ImVec2 p = ImGui::GetCursorScreenPos();
const float rowW = std::min(maxW, nameW + valueW + 520);
if (s.pick) {
if (ImGui::Selectable("##row", g_sel == i, 0, ImVec2(rowW, 0))) {
g_pending = s.text;
g_hasPending = true;
g_typed = s.text;
buildList();
g_focus = true;
}
} else {
ImGui::Dummy(ImVec2(rowW, ImGui::GetTextLineHeight()));
}
ImDrawList* dl = ImGui::GetWindowDrawList();
if (g_sel == i) dl->AddRectFilled(p, ImVec2(p.x + 3, p.y + ImGui::GetTextLineHeight()), accent);
dl->AddText(ImVec2(p.x + 8, p.y), IM_COL32(240, 236, 228, 255), s.label.c_str());
float x = p.x + 8 + nameW + 16;
if (!s.value.empty()) dl->AddText(ImVec2(x, p.y), IM_COL32(240, 120, 110, 255), ("\"" + s.value + "\"").c_str());
x += valueW + (valueW > 0 ? 16 : 0);
if (!s.help.empty()) {
ImGui::PushClipRect(ImVec2(x, p.y), ImVec2(p.x + rowW, p.y + 40), true);
dl->AddText(ImVec2(x, p.y), IM_COL32(150, 145, 138, 255), s.help.c_str());
ImGui::PopClipRect();
}
ImGui::PopID();
}
ImGui::End();
ImGui::PopStyleVar();
ImGui::PopStyleColor(2);
}
} // namespace keel::console

13
engine/core/console.h Normal file
View file

@ -0,0 +1,13 @@
#pragma once
#include <string>
namespace keel::console {
void toggle();
bool isOpen();
// Draws the console window with Dear ImGui. Call between ImGui::NewFrame and ImGui::Render.
void draw();
// Where typed lines are kept between runs (loaded now, saved as you type).
void setHistoryFile(const std::string& path);
} // namespace keel::console

322
engine/core/cvar.cpp Normal file
View file

@ -0,0 +1,322 @@
#include "core/cvar.h"
#include "core/log.h"
#include <algorithm>
#include <charconv>
#include <cstdio>
#include <cstdlib>
#include <utility>
namespace keel {
namespace {
// Function-local statics: safe to use from other files' static initializers.
std::vector<Cvar*>& cvars() { static std::vector<Cvar*> v; return v; }
std::vector<Cmd*>& cmds() { static std::vector<Cmd*> v; return v; }
std::vector<std::pair<std::string, std::string>>& aliasList() { static std::vector<std::pair<std::string, std::string>> v; return v; }
int g_aliasDepth = 0; // an alias that runs itself stops here
template <class T> T* findByName(std::vector<T*>& list, std::string_view name) {
auto it = std::find_if(list.begin(), list.end(), [&](T* x) { return name == x->name(); });
return it == list.end() ? nullptr : *it;
}
} // namespace
Cvar::Cvar(const char* name, const char* default_value, unsigned flags, const char* help)
: name_(name), help_(help), default_(default_value), flags_(flags) {
set(default_value);
cvars().push_back(this);
}
Cvar::~Cvar() { std::erase(cvars(), this); }
void Cvar::set(std::string_view value) {
value_ = value;
#if defined(__ANDROID__) // (the NDK's library has no float from_chars; bionic's locale is always "C")
const std::string text(value);
char* end = nullptr;
const float parsed = std::strtof(text.c_str(), &end);
float_ = end != text.c_str() ? parsed : 0.0f;
#else
float parsed = 0.0f;
auto [_, ec] = std::from_chars(value.data(), value.data() + value.size(), parsed);
float_ = ec == std::errc() ? parsed : 0.0f;
#endif
modified_ = true;
}
bool Cvar::consumeModified() { return std::exchange(modified_, false); }
Cvar* Cvar::find(std::string_view name) { return findByName(cvars(), name); }
const std::vector<Cvar*>& Cvar::all() { return cvars(); }
Cmd::Cmd(const char* name, Fn fn, const char* help) : name_(name), help_(help), fn_(std::move(fn)) {
cmds().push_back(this);
}
Cmd::~Cmd() { std::erase(cmds(), this); }
Cmd* Cmd::find(std::string_view name) { return findByName(cmds(), name); }
const std::vector<Cmd*>& Cmd::all() { return cmds(); }
Args tokenize(std::string_view line) {
Args out;
size_t i = 0;
while (i < line.size()) {
while (i < line.size() && line[i] <= ' ') ++i;
if (i >= line.size()) break;
if (line[i] == '"') {
size_t end = line.find('"', ++i);
if (end == std::string_view::npos) end = line.size();
out.emplace_back(line.substr(i, end - i));
i = end + 1;
} else {
size_t start = i;
while (i < line.size() && line[i] > ' ') ++i;
out.emplace_back(line.substr(start, i - start));
}
}
return out;
}
namespace {
struct Deferred {
int frames;
std::string text;
};
std::vector<Deferred> g_deferred;
} // namespace
void executeDeferred() {
std::vector<Deferred> due;
for (auto it = g_deferred.begin(); it != g_deferred.end();)
if (--it->frames <= 0) {
due.push_back(std::move(*it));
it = g_deferred.erase(it);
} else {
++it;
}
for (const Deferred& d : due) execute(d.text);
}
void execute(std::string_view text) {
// Split on ';' and newlines outside quotes.
bool quoted = false;
size_t start = 0;
for (size_t i = 0; i <= text.size(); ++i) {
if (i < text.size() && text[i] == '"') quoted = !quoted;
if (i < text.size() && (quoted || (text[i] != ';' && text[i] != '\n'))) continue;
Args args = tokenize(text.substr(start, i - start));
start = i + 1;
if (args.empty() || args[0].starts_with("//")) continue;
if (args[0] == "wait") {
const int frames = args.size() > 1 ? std::max(std::atoi(args[1].c_str()), 1) : 1;
if (i < text.size()) g_deferred.push_back({frames, std::string(text.substr(i + 1))});
return;
}
auto alias = std::find_if(aliasList().begin(), aliasList().end(), [&](const auto& a) { return a.first == args[0]; });
if (Cmd* cmd = Cmd::find(args[0])) {
cmd->run(args);
} else if (alias != aliasList().end()) {
if (g_aliasDepth > 32) {
log::warn("alias {} runs itself too deeply", args[0]);
continue;
}
++g_aliasDepth;
execute(alias->second);
--g_aliasDepth;
} else if (Cvar* cvar = Cvar::find(args[0])) {
if (args.size() == 1) {
log::info("\"{}\" = \"{}\" (default \"{}\"){}", cvar->name(), cvar->str(), cvar->defaultValue(),
cvar->flags() & CVAR_CHEAT ? " cheat" : "");
log::info(" {}", cvar->help());
} else if ((cvar->flags() & CVAR_CHEAT) && !Cvar::find("sv_cheats")->b()) {
log::warn("{} is cheat protected", cvar->name());
} else {
cvar->set(args[1]);
}
} else {
log::warn("Unknown command \"{}\"", args[0]);
}
}
}
static Cvar sv_cheats("sv_cheats", "0", CVAR_REPLICATE, "Allow cheat-protected cvars");
static Cmd cmd_echo("echo", [](const Args& a) {
std::string s;
for (size_t i = 1; i < a.size(); ++i) s += (i > 1 ? " " : "") + a[i];
log::info("{}", s);
}, "Print text to the console");
static Cmd cmd_set("set", [](const Args& a) {
if (a.size() < 3) return log::info("usage: set <cvar> <value>");
if (Cvar* c = Cvar::find(a[1])) c->set(a[2]);
else log::warn("Unknown cvar \"{}\"", a[1]);
}, "Set a cvar");
const std::vector<std::pair<std::string, std::string>>& aliases() { return aliasList(); }
std::vector<std::string> allNames() {
std::vector<std::string> out;
for (Cmd* c : Cmd::all()) out.emplace_back(c->name());
for (Cvar* c : Cvar::all()) out.emplace_back(c->name());
for (const auto& a : aliasList()) out.push_back(a.first);
std::sort(out.begin(), out.end());
out.erase(std::unique(out.begin(), out.end()), out.end());
return out;
}
std::vector<std::string> matching(const std::vector<std::string>& names, std::string_view partial) {
std::vector<std::string> out;
for (const std::string& n : names)
if (n.starts_with(partial)) out.push_back(n);
return out;
}
namespace {
std::string flagText(unsigned f) {
std::string s;
if (f & CVAR_ARCHIVE) s += " saved";
if (f & CVAR_CHEAT) s += " cheat";
if (f & CVAR_REPLICATE) s += " replicated";
return s;
}
bool contains(std::string_view hay, std::string_view needle) {
auto low = [](char c) { return char(c >= 'A' && c <= 'Z' ? c + 32 : c); };
return std::search(hay.begin(), hay.end(), needle.begin(), needle.end(), [&](char a, char b) { return low(a) == low(b); }) != hay.end();
}
Cmd::Completer names() {
return [](std::string_view p, size_t) { return matching(allNames(), p); };
}
Cmd::Completer cvarNames() {
return [](std::string_view p, size_t) {
std::vector<std::string> v;
for (Cvar* c : Cvar::all()) v.emplace_back(c->name());
std::sort(v.begin(), v.end());
return matching(v, p);
};
}
} // namespace
// toggle <cvar> [values...]: 0 ↔ 1, or the next of the values given (Source).
static Cmd cmd_toggle("toggle", [](const Args& a) {
if (a.size() < 2) return log::info("usage: toggle <cvar> [value1 value2 ...]");
Cvar* c = Cvar::find(a[1]);
if (!c) return log::warn("Unknown cvar \"{}\"", a[1]);
if (a.size() < 4) return execute(a[1] + (c->b() ? " 0" : " 1"));
size_t next = 2;
for (size_t i = 2; i < a.size(); ++i)
if (a[i] == c->str()) next = i + 1 < a.size() ? i + 1 : 2;
execute(a[1] + " \"" + a[next] + "\"");
}, "toggle <cvar> [values...]: flip between 0 and 1, or step through the values given");
static Cmd cmd_incrementvar("incrementvar", [](const Args& a) {
if (a.size() < 5) return log::info("usage: incrementvar <cvar> <min> <max> <step>");
Cvar* c = Cvar::find(a[1]);
if (!c) return log::warn("Unknown cvar \"{}\"", a[1]);
const float lo = float(std::atof(a[2].c_str())), hi = float(std::atof(a[3].c_str())), step = float(std::atof(a[4].c_str()));
float v = c->f() + step;
if (v > hi) v = lo;
if (v < lo) v = hi;
execute(std::string(a[1]) + " " + std::to_string(v));
}, "incrementvar <cvar> <min> <max> <step>: add step, wrapping round (bind it to keys)");
static Cmd cmd_find("find", [](const Args& a) {
if (a.size() < 2) return log::info("usage: find <text> (searches names and descriptions)");
int n = 0;
for (const std::string& name : allNames()) {
Cmd* cmd = Cmd::find(name);
Cvar* cv = cmd ? nullptr : Cvar::find(name);
const char* help = cmd ? cmd->help() : cv ? cv->help() : "";
if (!contains(name, a[1]) && !contains(help, a[1])) continue;
if (cv) log::info(" {} = \"{}\" - {}", name, cv->str(), help);
else log::info(" {} - {}", name, cmd ? help : "alias");
++n;
}
log::info("{} found", n);
}, "find <text>: commands and cvars whose name or description mentions it");
static Cmd cmd_help("help", [](const Args& a) {
if (a.size() < 2) {
log::info("Type a command or cvar name; a list of matches shows as you type (Tab completes, arrows pick).");
log::info(" help <name> what it does find <text> search names and descriptions");
log::info(" cvarlist [x] all cvars cmdlist [x] all commands");
log::info(" differences cvars you changed reset <cvar> back to its default");
log::info(" alias, bind, exec, toggle, incrementvar, echo, clear, condump, wait");
return;
}
if (Cmd* c = Cmd::find(a[1])) return log::info("{} (command) - {}", c->name(), c->help());
if (Cvar* c = Cvar::find(a[1])) {
log::info("\"{}\" = \"{}\" (default \"{}\"){}", c->name(), c->str(), c->defaultValue(), flagText(c->flags()));
return log::info(" {}", c->help());
}
for (const auto& [name, text] : aliasList())
if (name == a[1]) return log::info("{} (alias) = \"{}\"", name, text);
log::warn("Nothing called \"{}\". Try: find {}", a[1], a[1]);
}, "help [name]: how the console works, or what a command/cvar does");
static Cmd cmd_cvarlist("cvarlist", [](const Args& a) {
std::vector<Cvar*> v(Cvar::all().begin(), Cvar::all().end());
std::sort(v.begin(), v.end(), [](Cvar* x, Cvar* y) { return std::string_view(x->name()) < y->name(); });
int n = 0;
for (Cvar* c : v)
if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1]))
log::info(" {:<24} {:<10}{:<9} {}", c->name(), "\"" + c->str() + "\"", flagText(c->flags()), c->help()), ++n;
log::info("{} cvars", n);
}, "cvarlist [prefix]: every cvar, its value and what it does");
static Cmd cmd_cmdlist("cmdlist", [](const Args& a) {
std::vector<Cmd*> v(Cmd::all().begin(), Cmd::all().end());
std::sort(v.begin(), v.end(), [](Cmd* x, Cmd* y) { return std::string_view(x->name()) < y->name(); });
int n = 0;
for (Cmd* c : v)
if (a.size() < 2 || std::string_view(c->name()).starts_with(a[1])) log::info(" {:<20} {}", c->name(), c->help()), ++n;
log::info("{} commands", n);
}, "cmdlist [prefix]: every command and what it does");
static Cmd cmd_differences("differences", [](const Args&) {
int n = 0;
for (Cvar* c : Cvar::all())
if (c->str() != c->defaultValue()) log::info(" {} = \"{}\" (default \"{}\")", c->name(), c->str(), c->defaultValue()), ++n;
log::info("{} changed from their defaults", n);
}, "Cvars that aren't at their default value");
static Cmd cmd_reset("reset", [](const Args& a) {
if (a.size() < 2) return log::info("usage: reset <cvar>");
if (Cvar* c = Cvar::find(a[1])) c->set(c->defaultValue()), log::info("{} = \"{}\"", c->name(), c->str());
else log::warn("Unknown cvar \"{}\"", a[1]);
}, "reset <cvar>: back to its default value");
static Cmd cmd_alias("alias", [](const Args& a) {
auto& list = aliasList();
if (a.size() < 2) {
for (const auto& [name, text] : list) log::info(" {} = \"{}\"", name, text);
return log::info("{} aliases", list.size());
}
std::erase_if(list, [&](const auto& x) { return x.first == a[1]; });
if (a.size() < 3) return;
std::string text;
for (size_t i = 2; i < a.size(); ++i) text += (i > 2 ? " " : "") + a[i];
list.emplace_back(a[1], text);
}, "alias <name> \"commands; more commands\": make a new command (alias <name> alone removes it)");
static Cmd cmd_clear("clear", [](const Args&) { log::clearHistory(); }, "Clear the console");
static Cmd cmd_condump("condump", [](const Args& a) {
const std::string path = a.size() > 1 ? a[1] : "condump.txt";
std::FILE* f = std::fopen(path.c_str(), "w");
if (!f) return log::warn("Can't write {}", path);
for (const log::Line& l : log::history()) std::fprintf(f, "%s\n", l.text.c_str());
std::fclose(f);
log::info("Console written to {}", path);
}, "condump [file]: save the console's text to a file");
// What the console suggests for their arguments.
[[maybe_unused]] static const bool g_completers = [] {
cmd_help.complete(names());
for (Cmd* c : {&cmd_reset, &cmd_toggle, &cmd_incrementvar, &cmd_set}) c->complete(cvarNames());
return true;
}();
} // namespace keel

101
engine/core/cvar.h Normal file
View file

@ -0,0 +1,101 @@
#pragma once
// Quake-style console variables and commands.
//
// static keel::Cvar r_vsync("r_vsync", "1", keel::CVAR_ARCHIVE, "Wait for vertical sync");
// static keel::Cmd quit("quit", [](const keel::Args&) { ... }, "Quit");
//
// Both register themselves at static-init time and are reachable from the console.
#include <functional>
#include <utility>
#include <string>
#include <string_view>
#include <vector>
namespace keel {
enum CvarFlags : unsigned {
CVAR_NONE = 0,
CVAR_ARCHIVE = 1 << 0, // saved to config.cfg
CVAR_CHEAT = 1 << 1, // needs sv_cheats 1
CVAR_REPLICATE = 1 << 2, // server value is sent to clients
};
class Cvar {
public:
Cvar(const char* name, const char* default_value, unsigned flags, const char* help);
~Cvar(); // unregisters (scripts create and destroy cvars at runtime)
Cvar(const Cvar&) = delete;
Cvar& operator=(const Cvar&) = delete;
const char* name() const { return name_; }
const char* help() const { return help_; }
const std::string& defaultValue() const { return default_; }
unsigned flags() const { return flags_; }
const std::string& str() const { return value_; }
float f() const { return float_; }
int i() const { return int(float_); }
bool b() const { return float_ != 0.0f; }
void set(std::string_view value);
// True once after the value changed; lets systems react (e.g. recreate the swapchain).
bool consumeModified();
static Cvar* find(std::string_view name);
static const std::vector<Cvar*>& all();
private:
const char* name_;
const char* help_;
std::string default_;
std::string value_;
float float_ = 0.0f;
unsigned flags_;
bool modified_ = true;
};
using Args = std::vector<std::string>; // args[0] is the command name
class Cmd {
public:
using Fn = std::function<void(const Args&)>;
// Suggestions for the argument being typed (the console's list): given what's typed of
// it and which argument it is (1 = the first), the values it could be. Set with complete().
using Completer = std::function<std::vector<std::string>(std::string_view partial, size_t arg)>;
Cmd(const char* name, Fn fn, const char* help);
~Cmd();
Cmd(const Cmd&) = delete;
Cmd& operator=(const Cmd&) = delete;
const char* name() const { return name_; }
const char* help() const { return help_; }
void run(const Args& args) const { fn_(args); }
Cmd& complete(Completer c) { completer_ = std::move(c); return *this; }
const Completer& completer() const { return completer_; }
static Cmd* find(std::string_view name);
static const std::vector<Cmd*>& all();
private:
const char* name_;
const char* help_;
Fn fn_;
Completer completer_;
};
// alias name "commands": a new command that runs them (Quake/Source).
const std::vector<std::pair<std::string, std::string>>& aliases();
// Every command, cvar and alias name, sorted (for completion).
std::vector<std::string> allNames();
// The names of things in `names` that start with `partial` (completers use it).
std::vector<std::string> matching(const std::vector<std::string>& names, std::string_view partial);
// Runs a line of console text: "cmd args; name value; name". "wait [frames]" defers
// the rest of the line (Quake style), e.g. "map test; wait 120; screenshot".
void execute(std::string_view text);
// Runs text deferred by "wait" whose frames have passed. Called once per frame.
void executeDeferred();
// Splits a line into arguments, honouring "quoted strings".
Args tokenize(std::string_view line);
} // namespace keel

33
engine/core/log.cpp Normal file
View file

@ -0,0 +1,33 @@
#include "core/log.h"
#include <cstdio>
#include <mutex>
#include <string>
#if defined(__ANDROID__)
#include <android/log.h>
#endif
namespace keel::log {
namespace {
constexpr size_t kMaxLines = 4096;
std::mutex g_mutex;
std::vector<Line> g_lines;
} // namespace
void write(Level level, std::string_view text) {
static const char* const prefix[] = {"", "WARN: ", "ERROR: "};
std::lock_guard lock(g_mutex);
std::fprintf(level == Level::Info ? stdout : stderr, "%s%.*s\n", prefix[int(level)], int(text.size()), text.data());
std::fflush(level == Level::Info ? stdout : stderr);
#if defined(__ANDROID__) // (a phone has no console: adb logcat -s Fry3DX shows it)
static const int priority[] = {ANDROID_LOG_INFO, ANDROID_LOG_WARN, ANDROID_LOG_ERROR};
__android_log_print(priority[int(level)], "Fry3DX", "%.*s", int(text.size()), text.data());
#endif
if (g_lines.size() >= kMaxLines) g_lines.erase(g_lines.begin(), g_lines.begin() + kMaxLines / 4);
g_lines.push_back({level, std::string(text)});
}
const std::vector<Line>& history() { return g_lines; }
void clearHistory() { g_lines.clear(); }
} // namespace keel::log

25
engine/core/log.h Normal file
View file

@ -0,0 +1,25 @@
#pragma once
#include <format>
#include <string>
#include <string_view>
#include <vector>
namespace keel::log {
enum class Level { Info, Warn, Error };
struct Line {
Level level;
std::string text;
};
void write(Level level, std::string_view text);
// The last few thousand lines, for the console.
const std::vector<Line>& history();
void clearHistory();
template <class... A> void info(std::format_string<A...> f, A&&... a) { write(Level::Info, std::format(f, std::forward<A>(a)...)); }
template <class... A> void warn(std::format_string<A...> f, A&&... a) { write(Level::Warn, std::format(f, std::forward<A>(a)...)); }
template <class... A> void error(std::format_string<A...> f, A&&... a) { write(Level::Error, std::format(f, std::forward<A>(a)...)); }
} // namespace keel::log

21
engine/core/pch.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
// Precompiled for the engine's libraries (engine/CMakeLists.txt): the standard library and glm
// headers nearly every file pulls in, parsed once per library instead of once per file.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstring>
#include <filesystem>
#include <format>
#include <functional>
#include <map>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
#include <glm/glm.hpp>
#include <glm/gtc/matrix_transform.hpp>
#include <glm/gtc/quaternion.hpp>

117
engine/core/sysinfo.cpp Normal file
View file

@ -0,0 +1,117 @@
#include "core/sysinfo.h"
#include <cstdio>
#include <cstring>
#include <fstream>
#include <thread>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define PSAPI_VERSION 2 // (K32GetProcessMemoryInfo, in kernel32)
#include <windows.h>
#include <psapi.h>
#else
#include <sys/utsname.h>
#include <unistd.h>
#endif
#if defined(_MSC_VER)
#include <intrin.h>
#elif defined(__x86_64__) || defined(__i386__)
#include <cpuid.h>
#endif
namespace keel::sys {
namespace {
std::string trim(std::string s) {
while (!s.empty() && (s.back() == ' ' || s.back() == '\n' || s.back() == '\r' || s.back() == '\0')) s.pop_back();
size_t i = 0;
while (i < s.size() && s[i] == ' ') ++i;
return s.substr(i);
}
// The CPU's own name for itself (x86: cpuid's brand string; elsewhere /proc/cpuinfo).
std::string cpuName() {
#if defined(_MSC_VER) || defined(__x86_64__) || defined(__i386__)
unsigned regs[12] = {};
for (unsigned i = 0; i < 3; ++i) {
#if defined(_MSC_VER)
int r[4];
__cpuid(r, int(0x80000002u + i));
std::memcpy(regs + i * 4, r, sizeof r);
#else
__get_cpuid(0x80000002u + i, &regs[i * 4], &regs[i * 4 + 1], &regs[i * 4 + 2], &regs[i * 4 + 3]);
#endif
}
char brand[49] = {};
std::memcpy(brand, regs, 48);
if (brand[0]) return trim(brand);
#endif
std::ifstream in("/proc/cpuinfo");
for (std::string line; std::getline(in, line);)
for (const char* key : {"model name", "Hardware", "Processor"})
if (line.rfind(key, 0) == 0)
if (const size_t c = line.find(':'); c != std::string::npos) return trim(line.substr(c + 1));
return "unknown CPU";
}
std::string osName() {
#if defined(_WIN32)
// GetVersionEx lies to programs without a manifest; ntdll's RtlGetVersion doesn't.
using RtlGetVersionFn = LONG(WINAPI*)(PRTL_OSVERSIONINFOW);
RTL_OSVERSIONINFOW v{sizeof v};
if (auto fn = reinterpret_cast<RtlGetVersionFn>(reinterpret_cast<void*>(GetProcAddress(GetModuleHandleW(L"ntdll.dll"), "RtlGetVersion"))))
fn(&v);
const char* name = v.dwMajorVersion == 10 ? (v.dwBuildNumber >= 22000 ? "Windows 11" : "Windows 10") : "Windows";
return std::string(name) + " (build " + std::to_string(v.dwBuildNumber) + ")";
#else
utsname u{};
uname(&u);
std::string pretty;
std::ifstream in("/etc/os-release");
for (std::string line; std::getline(in, line);)
if (line.rfind("PRETTY_NAME=", 0) == 0) {
pretty = line.substr(12);
if (pretty.size() >= 2 && pretty.front() == '"') pretty = pretty.substr(1, pretty.size() - 2);
}
#if defined(__ANDROID__)
if (pretty.empty()) pretty = "Android";
#endif
if (pretty.empty()) pretty = u.sysname;
return pretty + " (" + u.sysname + " " + u.release + ")";
#endif
}
} // namespace
const Machine& machine() {
static const Machine m{osName(), cpuName(), int(std::thread::hardware_concurrency())};
return m;
}
Memory memory() {
Memory m;
#if defined(_WIN32)
MEMORYSTATUSEX s{sizeof s};
if (GlobalMemoryStatusEx(&s)) m.total = s.ullTotalPhys, m.used = s.ullTotalPhys - s.ullAvailPhys;
PROCESS_MEMORY_COUNTERS pmc{sizeof pmc};
if (K32GetProcessMemoryInfo(GetCurrentProcess(), &pmc, sizeof pmc)) m.ours = pmc.WorkingSetSize;
#else
uint64_t available = 0;
std::ifstream in("/proc/meminfo");
for (std::string line; std::getline(in, line);) {
unsigned long long kb = 0;
if (std::sscanf(line.c_str(), "MemTotal: %llu kB", &kb) == 1) m.total = kb * 1024;
else if (std::sscanf(line.c_str(), "MemAvailable: %llu kB", &kb) == 1) available = kb * 1024;
}
m.used = m.total > available ? m.total - available : 0;
long pages = 0, resident = 0;
if (std::FILE* f = std::fopen("/proc/self/statm", "r")) {
if (std::fscanf(f, "%ld %ld", &pages, &resident) != 2) resident = 0;
std::fclose(f);
}
m.ours = uint64_t(resident) * uint64_t(sysconf(_SC_PAGESIZE));
#endif
return m;
}
} // namespace keel::sys

21
engine/core/sysinfo.h Normal file
View file

@ -0,0 +1,21 @@
#pragma once
// What machine this is (the debug overlay's corner, crash reports): the OS, the CPU, memory.
#include <cstdint>
#include <string>
namespace keel::sys {
struct Machine {
std::string os; // "NixOS 25.11 (Linux 6.18.54)", "Windows 11 (build 26100)"
std::string cpu; // "AMD Ryzen 7 5700G with Radeon Graphics"
int threads = 0;
};
const Machine& machine(); // (worked out once)
struct Memory {
uint64_t total = 0, used = 0; // the whole machine's RAM (used: not available to programs)
uint64_t ours = 0; // this process's resident memory
};
Memory memory();
} // namespace keel::sys

13
engine/core/time.h Normal file
View file

@ -0,0 +1,13 @@
#pragma once
#include <chrono>
namespace keel {
// Seconds since program start, monotonic.
inline double now() {
using clock = std::chrono::steady_clock;
static const clock::time_point start = clock::now();
return std::chrono::duration<double>(clock::now() - start).count();
}
} // namespace keel