- Offline neural worker based on llama.cpp (fortune-engine.exe) with local Qwen3-4B model support - Native assembly Tarot 3-card spread divination and teacher grade divination mode - Text input dialog in MODE_FORTUNE communicating over anonymous pipes - Clean process lifecycle management, WM_CHAR dispatch and fallback handling - Test suites and packaging tools for offline release
256 lines
12 KiB
C++
256 lines
12 KiB
C++
#define WIN32_LEAN_AND_MEAN
|
|
#define NOMINMAX
|
|
#include <windows.h>
|
|
#include <intrin.h>
|
|
#include "llama.h"
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cstdint>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
#include <iterator>
|
|
#include <memory>
|
|
#include <stdexcept>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
|
|
namespace {
|
|
constexpr uint32_t request_magic = 0x31514346; // FCQ1
|
|
constexpr uint32_t response_magic = 0x31524346; // FCR1
|
|
constexpr uint32_t max_question = 768;
|
|
constexpr uint32_t max_answer = 8192;
|
|
constexpr int context_size = 4096;
|
|
constexpr int max_tokens = 512;
|
|
|
|
bool read_exact(void *data, DWORD length) {
|
|
auto *bytes = static_cast<char *>(data);
|
|
while (length) {
|
|
DWORD read = 0;
|
|
if (!ReadFile(GetStdHandle(STD_INPUT_HANDLE), bytes, length, &read, nullptr) || !read)
|
|
return false;
|
|
bytes += read;
|
|
length -= read;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool write_exact(const void *data, DWORD length) {
|
|
auto *bytes = static_cast<const char *>(data);
|
|
while (length) {
|
|
DWORD wrote = 0;
|
|
if (!WriteFile(GetStdHandle(STD_OUTPUT_HANDLE), bytes, length, &wrote, nullptr) || !wrote)
|
|
return false;
|
|
bytes += wrote;
|
|
length -= wrote;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::string convert(const std::string &input, UINT from, UINT to) {
|
|
int size = MultiByteToWideChar(from, 0, input.data(), static_cast<int>(input.size()), nullptr, 0);
|
|
if (size <= 0) return {};
|
|
std::wstring wide(size, L'\0');
|
|
MultiByteToWideChar(from, 0, input.data(), static_cast<int>(input.size()), wide.data(), size);
|
|
if (to == 1251) {
|
|
for (wchar_t &c : wide) {
|
|
if (c == 0x2014 || c == 0x2013 || c == 0x2011) c = L'-';
|
|
if (c == 0x201c || c == 0x201d || c == 0x00ab || c == 0x00bb) c = L'"';
|
|
if (c == 0x2018 || c == 0x2019) c = L'\'';
|
|
if (c == 0x00a0) c = L' ';
|
|
}
|
|
}
|
|
size = WideCharToMultiByte(to, 0, wide.data(), static_cast<int>(wide.size()), nullptr, 0, nullptr, nullptr);
|
|
std::string result(size, '\0');
|
|
WideCharToMultiByte(to, 0, wide.data(), static_cast<int>(wide.size()), result.data(), size, nullptr, nullptr);
|
|
return result;
|
|
}
|
|
|
|
bool reply(uint32_t status, const std::string &utf8 = {}) {
|
|
std::string text = convert(utf8, CP_UTF8, 1251);
|
|
if (text.size() > max_answer) text.resize(max_answer);
|
|
for (char &c : text) {
|
|
if (static_cast<unsigned char>(c) < 32 && c != '\n') c = ' ';
|
|
}
|
|
const uint32_t header[] = {response_magic, status, static_cast<uint32_t>(text.size())};
|
|
return write_exact(header, sizeof(header)) && write_exact(text.data(), static_cast<DWORD>(text.size()));
|
|
}
|
|
|
|
std::string load_text(const std::filesystem::path &path) {
|
|
std::ifstream input(path, std::ios::binary);
|
|
if (!input) throw std::runtime_error("Missing local prompt data.");
|
|
std::string result{std::istreambuf_iterator<char>(input), {}};
|
|
if (result.size() > 32768) throw std::runtime_error("Prompt data is too large.");
|
|
return result;
|
|
}
|
|
|
|
using Model = std::unique_ptr<llama_model, decltype(&llama_model_free)>;
|
|
using Context = std::unique_ptr<llama_context, decltype(&llama_free)>;
|
|
using Batch = std::unique_ptr<llama_batch_ext, decltype(&llama_batch_ext_free)>;
|
|
using Sampler = std::unique_ptr<llama_sampler, decltype(&llama_sampler_free)>;
|
|
|
|
struct Message { std::string role, text; };
|
|
|
|
std::string safe_message(std::string text) {
|
|
// Untrusted user/model text cannot introduce ChatML role delimiters.
|
|
for (const std::string marker : {"<|", "|>"}) {
|
|
for (size_t at = 0; (at = text.find(marker, at)) != std::string::npos; at += 2)
|
|
text.replace(at, marker.size(), " ");
|
|
}
|
|
return text;
|
|
}
|
|
|
|
class Engine {
|
|
Model model{nullptr, llama_model_free};
|
|
Context ctx{nullptr, llama_free};
|
|
Batch batch{nullptr, llama_batch_ext_free};
|
|
std::string system;
|
|
std::vector<std::string> cards;
|
|
std::vector<Message> history;
|
|
std::vector<llama_token> cached_tokens;
|
|
|
|
void decode(const llama_token *tokens, int count, int pos) {
|
|
llama_batch_ext_clear(batch.get());
|
|
for (int i = 0; i < count; ++i) {
|
|
int idx = llama_batch_ext_add_token(batch.get(), 0, tokens[i]);
|
|
llama_pos location = pos + i;
|
|
llama_batch_ext_set_pos(batch.get(), idx, &location);
|
|
}
|
|
llama_batch_ext_set_output_logits(batch.get(), count - 1, true);
|
|
if (llama_process(ctx.get(), LLAMA_PROCESS_TYPE_DECODE, batch.get()))
|
|
throw std::runtime_error("Local inference failed.");
|
|
}
|
|
|
|
public:
|
|
void reset() {
|
|
llama_memory_clear(llama_get_memory(ctx.get()), true);
|
|
cached_tokens.clear();
|
|
history.clear();
|
|
}
|
|
|
|
explicit Engine(const std::filesystem::path &root) {
|
|
system = load_text(root / "system-prompt.txt");
|
|
std::string catalogue = load_text(root / "cards.txt");
|
|
size_t at = 0;
|
|
while (at < catalogue.size()) {
|
|
size_t end = catalogue.find('\n', at);
|
|
std::string card = catalogue.substr(at, end == std::string::npos ? end : end - at);
|
|
if (!card.empty() && card.back() == '\r') card.pop_back();
|
|
if (!card.empty()) cards.push_back(card);
|
|
if (end == std::string::npos) break;
|
|
at = end + 1;
|
|
}
|
|
if (cards.size() != 22) throw std::runtime_error("Invalid tarot catalogue.");
|
|
llama_backend_init();
|
|
auto mp = llama_model_default_params();
|
|
mp.n_gpu_layers = 0;
|
|
const auto path = root / "models/fortune-qwen3-4b-q4_k_m.gguf";
|
|
model.reset(llama_model_load_from_file(path.u8string().c_str(), mp));
|
|
if (!model) throw std::runtime_error("Local model is missing or cannot be loaded.");
|
|
auto cp = llama_context_default_params();
|
|
cp.n_ctx = context_size;
|
|
cp.n_batch = 256;
|
|
cp.n_ubatch = 256;
|
|
cp.n_threads = static_cast<int>(std::clamp(std::thread::hardware_concurrency() / 2, 1u, 8u));
|
|
cp.n_threads_batch = cp.n_threads;
|
|
ctx.reset(llama_init_from_model(model.get(), cp));
|
|
if (!ctx) throw std::runtime_error("Not enough memory for the local model.");
|
|
batch.reset(llama_batch_ext_init(ctx.get()));
|
|
if (!batch) throw std::runtime_error("Cannot allocate the local inference batch.");
|
|
}
|
|
|
|
std::pair<std::string, bool> answer(const std::string &question, const std::array<uint32_t, 3> &draw) {
|
|
std::string user = "Question: " + safe_message(question) + "\nCards chosen by the game (do not change):\n";
|
|
for (size_t i = 0; i < draw.size(); ++i) {
|
|
user += std::to_string(i + 1) + ". " + cards[draw[i] % 22];
|
|
user += draw[i] >= 22 ? " (reversed)\n" : " (upright)\n";
|
|
}
|
|
std::vector<Message> messages{{"system", system}};
|
|
messages.insert(messages.end(), history.begin(), history.end());
|
|
messages.push_back({"user", user});
|
|
std::vector<llama_chat_message> chat;
|
|
for (const auto &message : messages) chat.push_back({message.role.c_str(), message.text.c_str()});
|
|
const char *tmpl = llama_model_chat_template(model.get(), nullptr);
|
|
if (!tmpl) throw std::runtime_error("The model has no chat template.");
|
|
std::vector<char> formatted(32768);
|
|
int length = llama_chat_apply_template(tmpl, chat.data(), chat.size(), true, formatted.data(), static_cast<int>(formatted.size()));
|
|
if (length < 0 || length >= static_cast<int>(formatted.size())) throw std::runtime_error("Conversation is too long.");
|
|
const auto *vocab = llama_model_get_vocab(model.get());
|
|
int count = -llama_tokenize(vocab, formatted.data(), length, nullptr, 0, true, true);
|
|
if (count <= 0 || count > context_size - max_tokens) throw std::runtime_error("Question is too long.");
|
|
std::vector<llama_token> tokens(count);
|
|
if (llama_tokenize(vocab, formatted.data(), length, tokens.data(), count, true, true) < 0)
|
|
throw std::runtime_error("Cannot tokenize the question.");
|
|
int prefix = 0;
|
|
while (prefix < count && prefix < static_cast<int>(cached_tokens.size()) &&
|
|
tokens[prefix] == cached_tokens[prefix]) ++prefix;
|
|
if (prefix == count) --prefix;
|
|
// Reuse only an exact token prefix; never reuse unrelated answer state.
|
|
if (!llama_memory_seq_rm(llama_get_memory(ctx.get()), 0, prefix, -1)) {
|
|
llama_memory_clear(llama_get_memory(ctx.get()), true);
|
|
prefix = 0;
|
|
}
|
|
for (int pos = prefix; pos < count; pos += 256) decode(tokens.data() + pos, std::min(256, count - pos), pos);
|
|
cached_tokens = tokens;
|
|
Sampler sampler(llama_sampler_chain_init(llama_sampler_chain_default_params()), llama_sampler_free);
|
|
llama_sampler_chain_add(sampler.get(), llama_sampler_init_top_k(40));
|
|
llama_sampler_chain_add(sampler.get(), llama_sampler_init_top_p(0.8f, 1));
|
|
llama_sampler_chain_add(sampler.get(), llama_sampler_init_temp(0.65f));
|
|
llama_sampler_chain_add(sampler.get(), llama_sampler_init_dist(LLAMA_DEFAULT_SEED));
|
|
std::string output;
|
|
bool complete = false;
|
|
for (int i = 0; i < max_tokens; ++i) {
|
|
llama_token token = llama_sampler_sample(sampler.get(), ctx.get(), -1);
|
|
if (llama_vocab_is_eog(vocab, token)) { complete = true; break; }
|
|
char piece[256];
|
|
int bytes = llama_token_to_piece(vocab, token, piece, sizeof(piece), 0, false);
|
|
if (bytes < 0) throw std::runtime_error("Cannot decode the answer.");
|
|
output.append(piece, bytes);
|
|
decode(&token, 1, count + i);
|
|
cached_tokens.push_back(token);
|
|
}
|
|
if (output.empty()) throw std::runtime_error("The model returned an empty answer.");
|
|
// Keep only one previous turn, in RAM, never in the player profile or logs.
|
|
history = {{"user", user}, {"assistant", safe_message(output)}};
|
|
return {output, complete};
|
|
}
|
|
};
|
|
}
|
|
|
|
int wmain() {
|
|
SetErrorMode(SEM_FAILCRITICALERRORS | SEM_NOGPFAULTERRORBOX | SEM_NOOPENFILEERRORBOX);
|
|
int cpu[4]{};
|
|
__cpuid(cpu, 1);
|
|
constexpr int avx_osxsave = (1 << 27) | (1 << 28);
|
|
if ((cpu[2] & avx_osxsave) != avx_osxsave || (_xgetbv(0) & 6) != 6) {
|
|
reply(2, "This local model requires an AVX-capable CPU and OS.");
|
|
return 1;
|
|
}
|
|
try {
|
|
std::array<wchar_t, 32768> executable{};
|
|
if (!GetModuleFileNameW(nullptr, executable.data(), static_cast<DWORD>(executable.size()))) return 1;
|
|
Engine engine(std::filesystem::path(executable.data()).parent_path());
|
|
if (!reply(0)) return 0; // Ready: no model or prompt content on stdout.
|
|
for (;;) {
|
|
std::array<uint32_t, 5> header{};
|
|
if (!read_exact(header.data(), sizeof(header))) return 0;
|
|
if (header[0] != request_magic || !header[1] || header[1] > max_question) return 2;
|
|
std::array<uint32_t, 3> cards{header[2], header[3], header[4]};
|
|
if (std::any_of(cards.begin(), cards.end(), [](uint32_t v) { return v >= 44; })) return 2;
|
|
if (cards[0] % 22 == cards[1] % 22 || cards[0] % 22 == cards[2] % 22 || cards[1] % 22 == cards[2] % 22) return 2;
|
|
std::string input(header[1], '\0');
|
|
if (!read_exact(input.data(), static_cast<DWORD>(input.size()))) return 0;
|
|
if (input.find('\0') != std::string::npos) return 2;
|
|
try {
|
|
const auto result = engine.answer(convert(input, 1251, CP_UTF8), cards);
|
|
if (!reply(result.second ? 1 : 3, result.first)) return 0;
|
|
} catch (const std::exception &error) {
|
|
engine.reset();
|
|
if (!reply(2, error.what())) return 0;
|
|
}
|
|
}
|
|
} catch (const std::exception &error) {
|
|
reply(2, error.what());
|
|
return 1;
|
|
}
|
|
}
|