Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,9 @@ if(WIN32)
if(MINGW)
target_compile_options(retcomm-portable PRIVATE
$<$<COMPILE_LANGUAGE:CXX>:-municode>)
target_link_options(retcomm-portable PRIVATE -municode)
# The stub starts before its payload DLLs have been extracted. Keep
# the compiler runtimes inside it as well as miniz.
target_link_options(retcomm-portable PRIVATE -municode -static)
endif()
target_link_libraries(retcomm-portable PRIVATE shell32 retcomm_miniz)
install(TARGETS retcomm-portable RUNTIME DESTINATION bin)
Expand Down Expand Up @@ -553,6 +555,11 @@ if(RETCOMM_HAVE_SDL3 AND RETCOMM_IMGUI_READY)
${RETCOMM_HUB_SDL_TARGET})
add_test(NAME hub_core_settings COMMAND retro-hub-settings-test
${CMAKE_CURRENT_BINARY_DIR}/hub_core_settings_test.d)
add_executable(retro-hub-netplay-options-test tests/netplay_options_test.cpp)
target_include_directories(retro-hub-netplay-options-test PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(retro-hub-netplay-options-test PRIVATE retcomm_core retro_corelink
${RETCOMM_HUB_SDL_TARGET})
add_test(NAME netplay_options COMMAND retro-hub-netplay-options-test)
# Romhack bases (romhacks.hpp): what the Base picker lists, a base's save
# dir, and a mod's standing on a base.
add_executable(retro-hub-romhack-test tests/hub_romhack_test.cpp)
Expand Down
2 changes: 1 addition & 1 deletion docs/NETPLAY_DIRECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ contract across the four repositories involved.
| Host Lobby: "Transfer Paks" on/off (`match_caps.tpak`); a Transfer Pak lobby needs all three ROMs set and rechecked, to host and to join (a join without them leaves, saying why) | **built**; PLAY in a Transfer Pak lobby waits until every seated player's pak has arrived |
| Pak exchange: each seated player's pak (which of the three carts, its save) goes to every peer before start, over the lobby's opaque `signal` (type `0x5450`, base64 chunks of 12 KiB, sha256 checked on arrival). A remote pak is kept under `<data>/netplay/<title>/paks/<player id>.sav`. At launch each seat's save is copied into the match's sandbox (`session-<id>/saves/tpakN.sav`) with the local ROM for that cart, so the runner's match key holds every seat's ROM and save hash | **built** |
| Host relay: a Direct-mode room asks for it (`match_caps.relay = "host"`). The host's hub holds the game port while the room waits (`netplay_nat` `HostPort`: UPnP IGD, else NAT-PMP, else STUN), advertises it with `set_host_endpoint` and answers probes; each guest probes it and sends `path_report`; the room says which way it went. At launch the host frees the port and its runner binds it | **built**; UPnP checked read-only against Alex's router (discovery, the WANIPConnection:1 control URL, GetExternalIPAddress); no mapping was added from here |
| PLAY -> `start` -> `launch` -> a PlaySession in netplay mode: `netplay_runner_args` turns the launch into `--net-*` (transport `sfu`: every peer dials the relay; `host`: guests dial the host); a fresh save sandbox per match (`<data>/netplay/<title>/session-<id>/saves`); the player's NETPLAY options dropped for the core's defaults; the game never pauses (the menu opens over it, the local pad neutral); no save states, no turbo; a runner exit 4 is "The match ended", not a fault | **built** |
| PLAY -> `start` -> `launch` -> a PlaySession in netplay mode: `netplay_runner_args` turns the launch into `--net-*` (transport `sfu`: every peer dials the relay; `host`: guests dial the host); a fresh save sandbox per match (`<data>/netplay/<title>/session-<id>/saves`); the host-selected fixed match choices (declared netplay.* enums) carried in the room and adopted before boot, other NETPLAY options using core defaults, and local display options preserved; the game never pauses (the menu opens over it, the local pad neutral); no save states, no turbo; a runner exit 4 is "The match ended", not a fault | **built** |

### 2. Runner (Retro-Runtime)

Expand Down
28 changes: 21 additions & 7 deletions scripts/build-local.sh
Original file line number Diff line number Diff line change
Expand Up @@ -135,8 +135,16 @@ cmake_args=(
"-DCMAKE_BUILD_TYPE=${CONFIG}"
"-DRETCOMM_COMMIT=${COMMIT}"
)
[[ -z "${RUNTIME}" ]] || cmake_args+=("-DRETCOMM_RUNTIME_DIR=${RUNTIME}")
[[ -z "${RELEASE_VERSION}" ]] || cmake_args+=("-DRETCOMM_VERSION=${RELEASE_VERSION}" -DRETCOMM_RELEASE_BUILD=ON)
if [[ -n "${RUNTIME}" ]]; then
cmake_args+=("-DRETCOMM_RUNTIME_DIR=${RUNTIME}")
else
cmake_args+=(-URETCOMM_RUNTIME_DIR)
fi
if [[ -n "${RELEASE_VERSION}" ]]; then
cmake_args+=("-DRETCOMM_VERSION=${RELEASE_VERSION}" -DRETCOMM_RELEASE_BUILD=ON)
else
cmake_args+=(-URETCOMM_VERSION -URETCOMM_RELEASE_BUILD)
fi
command -v ninja >/dev/null && cmake_args+=(-G Ninja)
[[ -z "${CMAKE_PREFIX_PATH:-}" ]] || cmake_args+=("-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}")
# The runner finds SDL3 beside itself in the flat prefix (the hub already
Expand All @@ -145,11 +153,15 @@ case "${OS}" in
linux) cmake_args+=("-DCMAKE_INSTALL_RPATH=\$ORIGIN") ;;
macos) cmake_args+=("-DCMAKE_INSTALL_RPATH=@loader_path") ;;
esac
# Another generator or other choices in an existing cache: start it afresh.
# Keep compiled objects and caller-supplied dependency paths when definitions
# change. Only a generator transition needs a fresh CMake cache.
STAMP="${BUILD}/build-local.args"
if [[ -f "${STAMP}" && "$(cat "${STAMP}")" != "$(printf '%s\n' "${cmake_args[@]}")" ]]; then
say "configuration changed since the last build in ${BUILD}; reconfiguring from scratch"
rm -rf "${BUILD}/CMakeCache.txt" "${BUILD}/CMakeFiles"
say "configuration changed since the last build in ${BUILD}; reconfiguring"
old_generator="$(sed -n 's/^CMAKE_GENERATOR:INTERNAL=//p' "${BUILD}/CMakeCache.txt" 2>/dev/null || true)"
if [[ -n "${old_generator}" && "${old_generator}" != Ninja ]] && command -v ninja >/dev/null; then
rm -rf "${BUILD}/CMakeCache.txt" "${BUILD}/CMakeFiles"
fi
fi
say "${PLATFORM}, ${CONFIG}, build ${BUILD}"
cmake "${cmake_args[@]}" >&2
Expand Down Expand Up @@ -196,7 +208,8 @@ done
# title-app kit's bundle_dylibs.sh copies it into an app.
SDL_NOTE="SDL3 from the system install name"
if [[ "${OS}" == linux ]]; then
sdl="$(ldd "${BUILD}/retro-hub" | awk '$1 ~ /^libSDL3\.so/ {print $1 " " $3; exit}')"
imports="$(ldd "${BUILD}/retro-hub")" || die "cannot inspect the built hub's imports"
sdl="$(awk '$1 ~ /^libSDL3\.so/ {print $1 " " $3; exit}' <<<"${imports}")"
[[ -n "${sdl}" ]] || die "retro-hub does not import libSDL3"
soname="${sdl%% *}" path="${sdl#* }"
[[ -f "${path}" ]] || die "retro-hub needs ${soname}, which does not resolve"
Expand All @@ -212,7 +225,8 @@ field() { sed -n "s/^$1 //p" <<<"${report}"; }
[[ "$(field title_app)" == 1 ]] || die "${HUB} --version has no 'title_app 1'"
[[ " $(field direct_mode_flags) " == *" --package "* ]] || die "${HUB} --version lists no --package"
if [[ "${OS}" == linux ]]; then
env -u LD_LIBRARY_PATH ldd "${HUB}" | grep -qF "${soname} => ${OUT}/${soname}" ||
imports="$(env -u LD_LIBRARY_PATH ldd "${HUB}")" || die "cannot inspect the flat hub's imports"
grep -qF "${soname} => ${OUT}/${soname}" <<<"${imports}" ||
die "${HUB} does not resolve ${soname} from the prefix"
fi
runner_report="$(env -u LD_LIBRARY_PATH "${OUT}/retro-core-runner" --version)" ||
Expand Down
24 changes: 20 additions & 4 deletions src/hub/hub_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#include "hub/hub_theme.hpp"
#include "hub/hub_widgets.hpp"
#include "hub/hub_netplay.hpp"
#include "hub/hub_netplay_options.hpp"

#if !defined(RETCOMM_COMMIT)
#define RETCOMM_COMMIT ""
Expand Down Expand Up @@ -17648,6 +17649,18 @@ void prepare_net_scope(DirectHome& h, HubModel& hub) {
(pkg_sha.empty() ? std::string() : ".p" + pkg_sha.substr(0, 10));
sc.max_slots = 4;
sc.tpak_supported = h.tpak_supported;
sc.room_choices = [&h, &hub] {
retcomm::hub::PlayArgs args = h.d.args;
apply_player_settings(args, hub.paths.data_dir, hub.exe_dir, h.platform, h.title_key);
std::vector<retcomm::hub::NetplayChoice> choices;
for (const auto& o : h.desc.options) {
if (!retcomm::hub::room_option(o)) continue;
const auto selected = args.options.find(o.key);
choices.push_back({o.key, o.label,
selected == args.options.end() ? o.default_value : selected->second, o.values});
}
return choices;
};
const fs::path data = hub.paths.data_dir;
const std::string platform = h.platform;
sc.tpak_problem = [data, platform]() -> std::string {
Expand Down Expand Up @@ -18178,14 +18191,17 @@ bool start_net_session(retcomm::hub::PlaySession& play, const DirectHome& h, con
std::string err;
retcomm::hub::PlayArgs args = h.d.args;
apply_player_settings(args, hub.paths.data_dir, hub.exe_dir, h.platform, h.title_key);
// NETPLAY-flagged options join the match key: each peer's own choice
// would only get the match refused, so every peer takes the core's default.
// NETPLAY-flagged options join the match key. Every peer adopts the room's
// validated host choices; other shared options take the core's defaults.
// Except the BASE (system.romhack): the stock game in a romhack player's
// place would be a silent substitution. It stays, so the match key admits
// peers on the same base and refuses peers on another.
if (!l.core_options.empty() && (!h.desc_ready || !h.desc.ok)) {
*why = "The core description is unavailable; the room settings cannot be validated.";
return false;
}
if (h.desc_ready && h.desc.ok) {
for (const auto& o : h.desc.options)
if (o.netplay && o.key != retcomm::kRomhackOption) args.options.erase(o.key);
if (!retcomm::hub::apply_room_options(h.desc.options, l.core_options, &args.options, why)) return false;
}
args.tpak_rom = {};
args.tpak_save = {};
Expand Down
58 changes: 55 additions & 3 deletions src/hub/hub_netplay.cpp
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
#include "hub/hub_netplay.hpp"
#include "hub/hub_netplay_options.hpp"
#include "hub/hub_overlay.hpp"

#include "hub/hub_model.hpp"
Expand Down Expand Up @@ -374,6 +375,7 @@ struct Page {
int host_max = 2;
bool host_spectators = false;
std::string host_error;
std::map<std::string, std::string> host_core_options;
char join_pw[64]{};
std::string join_pw_lobby, join_pw_game, join_pw_version;
char name_buf[64]{};
Expand Down Expand Up @@ -508,6 +510,34 @@ std::string local_name(const HubModel& hub) {
return hub.cfg.netplay.display_name;
}

void draw_host_room_choices(Page& p) {
if (!p.scope || !p.scope->room_choices) return;
for (const auto& choice : p.scope->room_choices()) {
if (choice.values.empty()) continue;
auto [it, inserted] = p.host_core_options.emplace(choice.key, choice.value);
if (std::find(choice.values.begin(), choice.values.end(), it->second) == choice.values.end())
it->second = choice.values.front();
ImGui::PushID(choice.key.c_str());
ImGui::TextUnformatted(choice.label.c_str());
if (ImGui::BeginCombo("##room_option", it->second.c_str())) {
for (const auto& value : choice.values)
if (ImGui::Selectable(value.c_str(), value == it->second)) it->second = value;
ImGui::EndCombo();
}
ImGui::PopID();
}
}

std::map<std::string, std::string> chosen_room_options(const Page& p) {
std::map<std::string, std::string> result;
if (!p.scope || !p.scope->room_choices) return result;
for (const auto& choice : p.scope->room_choices()) {
const auto selected = p.host_core_options.find(choice.key);
result[choice.key] = selected == p.host_core_options.end() ? choice.value : selected->second;
}
return result;
}

void ensure_init(HubModel& hub) {
Page& p = page();
if (p.init) return;
Expand Down Expand Up @@ -1580,6 +1610,7 @@ void draw_modals(HubModel& hub, const Theme& th, const std::vector<Game>& games)
ImGui::TextUnformatted("Lobby name");
ImGui::SetNextItemWidth(430.f);
ImGui::InputText("##host_name", p.host_name, sizeof(p.host_name));
draw_host_room_choices(p);
ImGui::TextUnformatted("Max Players");
const int top = std::min(8, g.max_slots);
p.host_max = std::clamp(p.host_max, 2, std::max(2, top));
Expand Down Expand Up @@ -1623,6 +1654,7 @@ void draw_modals(HubModel& hub, const Theme& th, const std::vector<Game>& games)
json caps = json::object();
caps["tpak"] = p.host_tpak;
caps["relay"] = "host"; // the lobby server's relay only as a fallback
caps["core_options"] = chosen_room_options(p);
p.client.create_for(g.game_name, g.pin, name, p.host_pw, p.host_max,
p.host_spectators, caps);
if (p.filter.empty()) p.scope_dirty = true;
Expand Down Expand Up @@ -1854,8 +1886,15 @@ void draw_modals(HubModel& hub, const Theme& th, const std::vector<Game>& games)
ImGui::TextColored(th.accent, "MATCH SETTINGS");
ImGui::SameLine();
ImGui::TextColored(th.text_muted, "(set by the host)");
if (p.scope && p.scope->room_choices && s.match_caps.contains("core_options")) {
for (const auto& choice : p.scope->room_choices()) {
const auto& selected = s.match_caps["core_options"];
if (selected.is_object() && selected.contains(choice.key) && selected[choice.key].is_string())
ImGui::Text("%s: %s", choice.label.c_str(), selected[choice.key].get_ref<const std::string&>().c_str());
}
}
for (auto it = s.match_caps.begin(); it != s.match_caps.end(); ++it) {
if (it.key() == "v" || it.key() == "mods") continue;
if (it.key() == "v" || it.key() == "mods" || it.key() == "core_options") continue;
ImGui::Text("%s: %s", it.key().c_str(), it->dump().c_str());
}
}
Expand Down Expand Up @@ -2402,6 +2441,7 @@ void ov_host(HubModel& hub, const Theme& th, const ov::Metrics& m, const std::ve
if (p.scope) ov::value_row(m, "game", "Game", g.title + " \xC2\xB7 " + g.pin);
else if (ov::setting_row(m, "game", "Game", g.title, "The game the lobby is for. Guests need the same build.", games.size() > 1))
ov::open_picker("##hostgame");
draw_host_room_choices(p);
{
std::vector<ov::PickerChoice> choices;
for (const Game& x : games) choices.push_back({x.title, "Version " + x.pin});
Expand Down Expand Up @@ -2449,6 +2489,7 @@ void ov_host(HubModel& hub, const Theme& th, const ov::Metrics& m, const std::ve
json caps = json::object();
caps["tpak"] = p.host_tpak;
caps["relay"] = "host"; // the lobby server's relay only as a fallback
caps["core_options"] = chosen_room_options(p);
p.client.create_for(g.game_name, g.pin, name, p.host_pw, p.host_max, p.host_spectators, caps);
if (p.filter.empty()) p.scope_dirty = true;
p.status = "Creating lobby...";
Expand Down Expand Up @@ -2682,9 +2723,16 @@ void ov_room(HubModel& hub, const Theme& th, const ov::Metrics& m, const std::ve
} else {
ov::value_row(m, "srv", "Lobby server", p.client_url);
ov::value_row(m, "lid", "Lobby id", s.lobby_id);
if (p.scope && p.scope->room_choices && s.match_caps.is_object() && s.match_caps.contains("core_options")) {
for (const auto& choice : p.scope->room_choices()) {
const auto& selected = s.match_caps["core_options"];
if (selected.is_object() && selected.contains(choice.key) && selected[choice.key].is_string())
ov::value_row(m, choice.key.c_str(), choice.label.c_str(), selected[choice.key].get<std::string>());
}
}
if (s.match_caps.is_object() && !s.match_caps.empty())
for (auto it = s.match_caps.begin(); it != s.match_caps.end(); ++it) {
if (it.key() == "v" || it.key() == "mods") continue;
if (it.key() == "v" || it.key() == "mods" || it.key() == "core_options") continue;
ov::value_row(m, it.key().c_str(), it.key().c_str(), it->dump());
}
}
Expand Down Expand Up @@ -3115,6 +3163,7 @@ void ingest(HubModel& hub) {
l.host_endpoint = s.launch.value("host_endpoint", std::string());
l.host = s.is_host;
l.tpak = s.match_caps.is_object() && s.match_caps.value("tpak", false);
const bool options_ok = read_room_options(s.match_caps, &l.core_options);
l.seat = -1;
const auto slots = s.launch.find("slots");
if (slots != s.launch.end() && slots->is_array()) {
Expand All @@ -3139,7 +3188,10 @@ void ingest(HubModel& hub) {
p.host_port.stop();
p.host_port_open = false;
}
if (l.seat < 0) {
if (!options_ok) {
p.room_status = "The room supplied invalid match options.";
p.client.ack_launch();
} else if (l.seat < 0) {
p.room_status = "The match started without you in a player seat "
"(spectating is not built yet).";
} else {
Expand Down
8 changes: 8 additions & 0 deletions src/hub/hub_netplay.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <array>
#include <cstdint>
#include <functional>
#include <map>
#include <string>
#include <vector>

Expand All @@ -37,6 +38,12 @@ struct NetplayLaunch {
// Transfer Pak lobbies: every seat's cartridge (this machine's copy of
// it) and that seat's save as the room exchanged it. Empty = no pak.
std::array<std::string, 4> tpak_rom, tpak_save;
std::map<std::string, std::string> core_options;
};

struct NetplayChoice {
std::string key, label, value;
std::vector<std::string> values;
};

// One player's Transfer Pak, as brought to a match: which of the supported
Expand All @@ -60,6 +67,7 @@ struct NetplayScope {
std::string pin; // the lobby's wire version: this build, exactly
int max_slots = 4;
bool tpak_supported = false;
std::function<std::vector<NetplayChoice>()> room_choices;
// "" when all three Transfer Pak cartridges are set and check out.
std::function<std::string()> tpak_problem;
// "" when this machine's seats can join a match at all; else why not
Expand Down
49 changes: 49 additions & 0 deletions src/hub/hub_netplay_options.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#pragma once

#include "hub/hub_core_settings.hpp"
#include <algorithm>
#include <map>
#include <nlohmann/json.hpp>

namespace retcomm::hub {

// Explicit room controls use the netplay.* namespace. Other simulation
// options retain their existing common defaults; display options stay local.
inline bool room_option(const CoreOptionDecl& o) {
return o.netplay && o.key.rfind("netplay.", 0) == 0 && o.type == "enum" && !o.values.empty();
}

inline bool read_room_options(const nlohmann::json& caps,
std::map<std::string, std::string>* out) {
out->clear();
if (!caps.is_object() || !caps.contains("core_options")) return true;
const auto& options = caps["core_options"];
if (!options.is_object() || options.size() > 32) return false;
for (auto it = options.begin(); it != options.end(); ++it) {
if (!it.value().is_string() || it.key().size() > 128 || it.value().get_ref<const std::string&>().size() > 128)
return false;
out->emplace(it.key(), it.value().get<std::string>());
}
return true;
}

inline bool apply_room_options(const std::vector<CoreOptionDecl>& decl,
const std::map<std::string, std::string>& room,
std::map<std::string, std::string>* local,
std::string* error) {
for (const auto& [key, value] : room) {
const auto found = std::find_if(decl.begin(), decl.end(), [&](const auto& o) { return o.key == key; });
if (found == decl.end() || !room_option(*found) ||
std::find(found->values.begin(), found->values.end(), value) == found->values.end()) {
*error = "The room selected an unsupported match option: " + key;
return false;
}
}
for (const auto& o : decl) {
if (o.netplay && o.key != "system.romhack") local->erase(o.key);
}
local->insert(room.begin(), room.end());
return true;
}

} // namespace retcomm::hub
Loading
Loading