#include "window_adapter.hpp"
#include <algorithm>
#include <cctype>
#include <charconv>
#include <cstdlib>
#include <cstdio>
#include <cstring>
#include <exception>
#include <stdexcept>
#include <utility>
extern "C"
{
#include "hack.h"
#include "func_tab.h"
}
namespace nethack_mcp
{
namespace
{
using ShimCallback = void (*)(const char*, void*, const char*, ...);
extern "C" void shim_graphics_set_callback(ShimCallback callback);
constexpr int MAP_WIDTH = 79;
constexpr int MAP_HEIGHT = 21;
constexpr std::size_t MAX_MESSAGES = 10;
constexpr std::size_t MAX_TEXT_BYTES = 4096;
constexpr const char* STATUS_NAMES[MAXBLSTATS] = {
"title", "strength", "dexterity", "constitution", "intelligence",
"wisdom", "charisma", "alignment", "score", "carrying-capacity",
"gold", "power", "power-max", "experience-level", "armor-class",
"HD", "time", "hunger", "hitpoints", "hitpoints-max",
"dungeon-level", "experience", "condition", "weapon", "armor",
"terrain", "version",
};
std::string copyString(const char* value)
{
return value == nullptr ? std::string() : std::string(value);
}
std::string statusKey(const std::string& name)
{
if(name == "hitpoints") return "hp";
if(name == "hitpoints-max") return "max_hp";
if(name == "power") return "power";
if(name == "power-max") return "max_power";
std::string key = name;
std::replace(key.begin(), key.end(), '-', '_');
return key;
}
Json statusText(const char* value, bool parse_number)
{
const std::string encoded = copyString(value);
std::string display(encoded.size() + 1, '\0');
decode_mixed(display.data(), encoded.c_str());
display.resize(std::strlen(display.c_str()));
Json result = {{"text", display}};
long long number = 0;
const auto parsed = std::from_chars(display.data(),
display.data() + display.size(),
number);
if(parse_number && !display.empty() && parsed.ec == std::errc()
&& parsed.ptr == display.data() + display.size())
{
result["value"] = number;
}
return result;
}
} // namespace
WindowAdapter* WindowAdapter::active_adapter_ = nullptr;
WindowAdapter::WindowAdapter(FramedChannel& channel, std::string game_id)
: channel_(channel), game_id_(std::move(game_id)),
map_rows_(MAP_HEIGHT, std::string(MAP_WIDTH, ' '))
{}
void WindowAdapter::install()
{
active_adapter_ = this;
shim_graphics_set_callback(&WindowAdapter::callback);
}
void WindowAdapter::callback(const char* name, void* return_ptr,
const char* format, ...)
{
if(active_adapter_ == nullptr)
{
return;
}
va_list arguments;
va_start(arguments, format);
try
{
active_adapter_->handleCallback(name, return_ptr, format, arguments);
}
catch(const std::exception& exception)
{
std::fprintf(stderr, "NetHack callback failed: %s\n",
exception.what());
std::_Exit(2);
}
catch(...)
{
std::fputs("NetHack callback failed with an unknown exception\n",
stderr);
std::_Exit(2);
}
va_end(arguments);
}
void WindowAdapter::handleCallback(const char* name, void* return_ptr,
const char* format,
std::va_list arguments)
{
if(name == nullptr)
{
throw std::runtime_error("NetHack sent a nameless callback");
}
if(std::strcmp(name, "shim_init_nhwindows") == 0)
{
return;
}
if(std::strcmp(name, "shim_create_nhwindow") == 0)
{
const int type = va_arg(arguments, int);
const int window = next_window_id_++;
window_types_[window] = type;
setIntegerReturn(return_ptr, window);
return;
}
if(std::strcmp(name, "shim_destroy_nhwindow") == 0)
{
return;
}
if(std::strcmp(name, "shim_clear_nhwindow") == 0)
{
const int window = va_arg(arguments, int);
if(window_types_[window] == NHW_MAP)
{
map_rows_.assign(MAP_HEIGHT, std::string(MAP_WIDTH, ' '));
}
return;
}
if(std::strcmp(name, "shim_curs") == 0)
{
const int window = va_arg(arguments, int);
const int x = va_arg(arguments, int);
const int y = va_arg(arguments, int);
if(window_types_[window] == NHW_MAP)
{
cursor_x_ = x;
cursor_y_ = y;
}
return;
}
if(std::strcmp(name, "shim_print_glyph") == 0)
{
const int window = va_arg(arguments, int);
const int x = va_arg(arguments, int);
const int y = va_arg(arguments, int);
const auto* glyph = va_arg(arguments, const glyph_info*);
(void) va_arg(arguments, const glyph_info*);
if(window_types_[window] == NHW_MAP && glyph != nullptr
&& y >= 0 && y < MAP_HEIGHT
&& x >= 1 && x <= MAP_WIDTH)
{
const unsigned char symbol = static_cast<unsigned char>(
glyph->ttychar);
map_rows_[y][x - 1] = std::isprint(symbol)
? static_cast<char>(symbol) : '?';
}
return;
}
if(std::strcmp(name, "shim_putstr") == 0)
{
const int window = va_arg(arguments, int);
(void) va_arg(arguments, int);
const char* text = va_arg(arguments, const char*);
if(text == nullptr)
{
return;
}
if(window_types_[window] == NHW_STATUS)
{
status_["text"] = text;
}
else if(window_types_[window] == NHW_MESSAGE)
{
addMessage(text);
}
else if(window_types_[window] == NHW_TEXT)
{
text_window_.append(text);
if(text_window_.size() > MAX_TEXT_BYTES)
{
text_window_.resize(MAX_TEXT_BYTES);
}
}
return;
}
if(std::strcmp(name, "shim_raw_print") == 0
|| std::strcmp(name, "shim_raw_print_bold") == 0)
{
addMessage(va_arg(arguments, const char*));
return;
}
if(std::strcmp(name, "shim_putmsghistory") == 0)
{
addMessage(va_arg(arguments, const char*));
(void) va_arg(arguments, int);
return;
}
if(std::strcmp(name, "shim_start_menu") == 0)
{
active_menu_window_ = va_arg(arguments, int);
(void) va_arg(arguments, unsigned long);
menu_entries_.clear();
menu_prompt_.clear();
next_menu_entry_id_ = 1;
return;
}
if(std::strcmp(name, "shim_add_menu") == 0)
{
(void) va_arg(arguments, int);
(void) va_arg(arguments, const glyph_info*);
const auto* identifier = va_arg(arguments, const anything*);
const int letter = va_arg(arguments, int);
(void) va_arg(arguments, int);
(void) va_arg(arguments, int);
(void) va_arg(arguments, int);
const char* text = va_arg(arguments, const char*);
const unsigned item_flags = va_arg(arguments, unsigned int);
MenuEntry entry;
entry.entry_id = next_menu_entry_id_++;
entry.text = copyString(text);
entry.selectable = identifier != nullptr;
entry.selected = (item_flags & MENU_ITEMFLAGS_SELECTED) != 0;
entry.letter = static_cast<char>(letter);
if(identifier != nullptr)
{
entry.identifier.resize(sizeof(anything));
std::memcpy(entry.identifier.data(), identifier,
sizeof(anything));
}
menu_entries_.push_back(std::move(entry));
return;
}
if(std::strcmp(name, "shim_end_menu") == 0)
{
(void) va_arg(arguments, int);
menu_prompt_ = copyString(va_arg(arguments, const char*));
return;
}
if(std::strcmp(name, "shim_select_menu") == 0)
{
const int window = va_arg(arguments, int);
const int how = va_arg(arguments, int);
auto** menu_list = va_arg(arguments, menu_item**);
if(inventory_requested_ && window == active_menu_window_
&& how == PICK_ONE && menu_prompt_.empty())
{
Json entries = Json::array();
for(const MenuEntry& entry : menu_entries_)
{
const unsigned char letter =
static_cast<unsigned char>(entry.letter);
if(entry.selectable && std::isprint(letter))
{
entries.push_back({
{"letter", std::string(1, entry.letter)},
{"text", entry.text},
});
}
}
inventory_["known"] = true;
inventory_["stale"] = false;
inventory_["entries"] = std::move(entries);
}
inventory_requested_ = false;
Json pending = makePending("menu", name);
pending["mode"] = how;
pending["prompt"] = menu_prompt_;
pending["entries"] = Json::array();
for(const MenuEntry& entry : menu_entries_)
{
pending["entries"].push_back({
{"entry_id", entry.entry_id},
{"text", entry.text},
{"selectable", entry.selectable},
{"selected", entry.selected},
});
}
const Json response = waitForInput(std::move(pending));
if(menu_list == nullptr || response.value("cancel", false))
{
setIntegerReturn(return_ptr, 0);
return;
}
const Json selections = response.value("selections", Json::array());
if(selections.empty())
{
setIntegerReturn(return_ptr, 0);
return;
}
auto* selected = static_cast<menu_item*>(std::malloc(
sizeof(menu_item) * selections.size()));
if(selected == nullptr)
{
throw std::runtime_error("could not allocate menu response");
}
std::size_t selected_count = 0;
for(const Json& item : selections)
{
const int entry_id = item.at("entry_id").get<int>();
const auto found = std::find_if(
menu_entries_.begin(), menu_entries_.end(),
[entry_id](const MenuEntry& entry) {
return entry.entry_id == entry_id && entry.selectable;
});
if(found == menu_entries_.end())
{
std::free(selected);
throw std::runtime_error("unknown menu entry response");
}
std::memset(&selected[selected_count].item, 0,
sizeof(selected[selected_count].item));
std::memcpy(&selected[selected_count].item,
found->identifier.data(), sizeof(anything));
selected[selected_count].count = item.value("count", -1L);
selected[selected_count].itemflags =
MENU_ITEMFLAGS_SELECTED;
++selected_count;
}
*menu_list = selected;
setIntegerReturn(return_ptr, static_cast<int>(selected_count));
return;
}
if(std::strcmp(name, "shim_message_menu") == 0)
{
const int letter = va_arg(arguments, int);
const int how = va_arg(arguments, int);
const std::string label = copyString(va_arg(arguments,
const char*));
if(inventory_requested_ && how == PICK_ONE
&& std::isprint(static_cast<unsigned char>(letter)))
{
inventory_["known"] = true;
inventory_["stale"] = false;
inventory_["entries"] = Json::array({{
{"letter", std::string(1, static_cast<char>(letter))},
{"text", label},
}});
}
inventory_requested_ = false;
addMessage(label.c_str());
setCharacterReturn(return_ptr, '\0');
return;
}
if(std::strcmp(name, "shim_display_file") == 0)
{
const std::string filename = copyString(
va_arg(arguments, const char*));
(void) va_arg(arguments, int);
Json pending = makePending("acknowledge", name);
pending["filename"] = filename;
pending["text"] = text_window_;
(void) waitForInput(std::move(pending));
text_window_.clear();
return;
}
if(std::strcmp(name, "shim_display_nhwindow") == 0)
{
const int window = va_arg(arguments, int);
const int blocking = va_arg(arguments, int);
if(blocking != 0 && window_types_[window] != NHW_MENU)
{
Json pending = makePending("acknowledge", name);
pending["text"] = text_window_;
(void) waitForInput(std::move(pending));
text_window_.clear();
}
return;
}
if(std::strcmp(name, "shim_wait_synch") == 0)
{
Json pending = makePending("acknowledge", name);
(void) waitForInput(std::move(pending));
return;
}
if(std::strcmp(name, "shim_nhgetch") == 0
|| std::strcmp(name, "shim_nh_poskey") == 0)
{
Json pending = makePending("key", name);
const Json response = waitForInput(std::move(pending));
const int value = response.value("value", 27);
inventory_requested_ = value == 'i';
if(std::strcmp(name, "shim_nh_poskey") == 0)
{
auto* x = va_arg(arguments, coordxy*);
auto* y = va_arg(arguments, coordxy*);
auto* modifier = va_arg(arguments, int*);
if(x != nullptr) *x = static_cast<coordxy>(cursor_x_);
if(y != nullptr) *y = static_cast<coordxy>(cursor_y_);
if(modifier != nullptr) *modifier = 0;
}
setIntegerReturn(return_ptr, value);
return;
}
if(std::strcmp(name, "shim_yn_function") == 0)
{
const std::string query = copyString(va_arg(arguments, const char*));
const char* choice_list = va_arg(arguments, const char*);
const std::string choices = copyString(choice_list);
const int default_value = va_arg(arguments, int);
Json pending = makePending("choice", name);
pending["query"] = query;
pending["choices"] = choices;
pending["has_choices"] = choice_list != nullptr;
pending["default"] = default_value;
const Json response = waitForInput(std::move(pending));
const int value = response.value("value", 27);
setCharacterReturn(return_ptr, static_cast<char>(value));
return;
}
if(std::strcmp(name, "shim_getlin") == 0)
{
const std::string query = copyString(va_arg(arguments, const char*));
auto* buffer = va_arg(arguments, char*);
Json pending = makePending("text", name);
pending["query"] = query;
pending["max_bytes"] = 255;
const Json response = waitForInput(std::move(pending));
const std::string text = response.value("cancel", false)
? std::string(1, 27)
: response.value("text", std::string());
if(buffer != nullptr)
{
const std::size_t length = std::min<std::size_t>(
text.size(), 255);
std::memcpy(buffer, text.data(), length);
buffer[length] = '\0';
}
return;
}
if(std::strcmp(name, "shim_get_ext_cmd") == 0)
{
Json pending = makePending("command", name);
pending["commands"] = Json::array();
for(const ext_func_tab* command = extcmdlist;
command != nullptr && command->ef_txt != nullptr; ++command)
{
pending["commands"].push_back(command->ef_txt);
}
const Json response = waitForInput(std::move(pending));
setIntegerReturn(return_ptr,
resolveCommand(response.value("command", "")));
return;
}
if(std::strcmp(name, "shim_get_color_string") == 0)
{
copyTextReturn(return_ptr, "");
return;
}
if(std::strcmp(name, "shim_getmsghistory") == 0)
{
(void) va_arg(arguments, int);
if(return_ptr != nullptr)
{
*static_cast<char**>(return_ptr) = nullptr;
}
return;
}
if(std::strcmp(name, "shim_update_inventory") == 0)
{
inventory_["stale"] = true;
return;
}
if(std::strcmp(name, "shim_status_init") == 0)
{
status_.clear();
status_fields_.clear();
for(int field = 0; field < MAXBLSTATS; ++field)
{
status_fields_[field] = statusKey(STATUS_NAMES[field]);
}
return;
}
if(std::strcmp(name, "shim_status_enablefield") == 0)
{
const int field = va_arg(arguments, int);
const std::string name = copyString(va_arg(arguments, const char*));
(void) va_arg(arguments, const char*);
const int enabled = va_arg(arguments, int);
if(field >= 0 && field < MAXBLSTATS)
{
const std::string key = statusKey(name);
if(enabled)
{
status_fields_[field] = key;
}
else
{
status_.erase(key);
status_fields_.erase(field);
}
}
return;
}
if(std::strcmp(name, "shim_status_update") == 0)
{
const int field = va_arg(arguments, int);
const void* value = va_arg(arguments, const void*);
(void) va_arg(arguments, int);
(void) va_arg(arguments, int);
(void) va_arg(arguments, int);
(void) va_arg(arguments, const unsigned long*);
const auto found = status_fields_.find(field);
if(found == status_fields_.end() || value == nullptr)
{
return;
}
if(field == BL_CONDITION)
{
const unsigned long mask = *static_cast<const unsigned long*>(
value);
Json active = Json::array();
for(int index = 0; index < CONDITION_COUNT; ++index)
{
if(mask & conditions[index].mask)
{
active.push_back(conditions[index].text[0]);
}
}
status_[found->second] = {
{"mask", mask}, {"active", std::move(active)},
};
}
else
{
Json formatted = statusText(
static_cast<const char*>(value), field != BL_HUNGER);
if(field == BL_GOLD)
{
const std::string display =
formatted.at("text").get<std::string>();
const std::size_t colon = display.rfind(':');
if(colon != std::string::npos)
{
long long amount = 0;
const char* first = display.data() + colon + 1;
const char* last = display.data() + display.size();
const auto parsed = std::from_chars(first, last, amount);
if(first != last && parsed.ec == std::errc()
&& parsed.ptr == last)
{
formatted["text"] = std::to_string(amount);
formatted["value"] = amount;
}
}
}
status_[found->second] = std::move(formatted);
}
return;
}
if(std::strcmp(name, "shim_exit_nhwindows") == 0)
{
addMessage(va_arg(arguments, const char*));
return;
}
if(std::strcmp(name, "shim_nhbell") == 0
|| std::strcmp(name, "shim_mark_synch") == 0
|| std::strcmp(name, "shim_delay_output") == 0
|| std::strcmp(name, "shim_resume_nhwindows") == 0
|| std::strcmp(name, "shim_suspend_nhwindows") == 0
|| std::strcmp(name, "shim_number_pad") == 0
|| std::strcmp(name, "shim_change_color") == 0
|| std::strcmp(name, "shim_change_background") == 0
|| std::strcmp(name, "shim_preference_update") == 0
|| std::strcmp(name, "shim_player_selection") == 0
|| std::strcmp(name, "shim_ctrl_nhwindow") == 0)
{
return;
}
if(return_ptr != nullptr && format != nullptr && format[0] != 'v')
{
std::fprintf(stderr, "Unhandled NetHack callback: %s (%s)\n",
name, format);
std::memset(return_ptr, 0, sizeof(int));
}
}
Json WindowAdapter::makeSnapshot() const
{
Json snapshot = {
{"schema_version", 1},
{"game_id", game_id_},
{"lifecycle", "waiting"},
{"operation", nullptr},
{"map", {
{"width", MAP_WIDTH},
{"height", MAP_HEIGHT},
{"origin", {{"x", 1}, {"y", 0}}},
{"rows", map_rows_},
{"cursor", {{"x", cursor_x_}, {"y", cursor_y_}}},
}},
{"status", status_},
{"messages", messages_},
{"messages_truncated", messages_truncated_},
{"inventory", inventory_},
{"pending", nullptr},
{"private_location", {{"depth", static_cast<int>(depth(&u.uz))}}},
};
return snapshot;
}
void WindowAdapter::reportTerminalResult(int how)
{
if(active_adapter_ == nullptr)
{
return;
}
std::string reason = "failed";
if(how == ASCENDED)
{
reason = "ascended";
}
else if(how == ESCAPED)
{
reason = "escaped";
}
else if(how == QUIT)
{
reason = "quit";
}
else if(how >= DIED && how < PANICKED)
{
reason = "died";
}
std::string error;
if(!active_adapter_->channel_.send({
{"type", "terminal_result"},
{"game_id", active_adapter_->game_id_},
{"native_how", how},
{"end_reason", reason},
}, error))
{
std::fprintf(stderr, "could not send terminal result: %s\n",
error.c_str());
}
}
Json WindowAdapter::makePending(std::string kind, std::string source) const
{
return {
{"input_id", input_id_ + 1},
{"kind", std::move(kind)},
{"source", std::move(source)},
};
}
Json WindowAdapter::waitForInput(Json pending)
{
if(pending.value("kind", "") != "menu")
{
inventory_requested_ = false;
}
input_id_++;
pending["input_id"] = input_id_;
Json snapshot = makeSnapshot();
snapshot["pending"] = pending;
publishSnapshot(std::move(snapshot));
while(true)
{
Json message;
std::string error;
if(!channel_.receive(message, error))
{
throw std::runtime_error(error);
}
if(message.value("type", "") == "shutdown")
{
throw std::runtime_error("worker shutdown requested");
}
if(message.value("type", "") != "input"
|| message.value("game_id", "") != game_id_
|| message.value("input_id", 0ULL) != input_id_)
{
continue;
}
inventory_["stale"] = true;
return message.value("response", Json::object());
}
}
void WindowAdapter::publishSnapshot(Json snapshot)
{
Json message = {
{"type", "snapshot"},
{"ipc_version", IPC_VERSION},
{"game_id", game_id_},
{"sequence", ++sequence_},
{"snapshot", std::move(snapshot)},
};
std::string error;
if(!channel_.send(message, error))
{
throw std::runtime_error(error);
}
}
void WindowAdapter::addMessage(const char* message)
{
const std::string text = copyString(message);
if(text.empty())
{
return;
}
static std::uint64_t message_id = 0;
messages_.push_back({
{"id", ++message_id},
{"text", text},
});
if(messages_.size() > MAX_MESSAGES)
{
messages_.erase(messages_.begin());
messages_truncated_ = true;
}
}
void WindowAdapter::setIntegerReturn(void* return_ptr, int value) const
{
if(return_ptr != nullptr)
{
*static_cast<int*>(return_ptr) = value;
}
}
void WindowAdapter::setCharacterReturn(void* return_ptr, char value) const
{
if(return_ptr != nullptr)
{
*static_cast<char*>(return_ptr) = value;
}
}
void WindowAdapter::copyTextReturn(void* return_ptr,
const std::string& text) const
{
if(return_ptr != nullptr)
{
auto** destination = static_cast<char**>(return_ptr);
if(text.empty())
{
static char empty[] = "";
*destination = empty;
}
else
{
*destination = const_cast<char*>(text.c_str());
}
}
}
int WindowAdapter::resolveCommand(const std::string& command) const
{
if(command.empty())
{
return -1;
}
int index = 0;
for(const ext_func_tab* entry = extcmdlist;
entry != nullptr && entry->ef_txt != nullptr; ++entry, ++index)
{
if(command == entry->ef_txt)
{
return index;
}
}
return -1;
}
} // namespace nethack_mcp
extern "C" void nethack_mcp_end_result(int how)
{
nethack_mcp::WindowAdapter::reportTerminalResult(how);
}