Skip to content
Merged
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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
20 changes: 16 additions & 4 deletions .github/bump_version.sh
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,19 @@ fi
# Default bump type is patch
BUMP_TYPE="patch"

# Define arrays for directories that trigger a major or minor bump
# Define arrays for directories that trigger a major or minor bump.
# Matched as path prefixes against `git diff --name-only`, so a directory entry
# covers everything beneath it and a file entry matches that file exactly.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
major_paths=(
"code/framework/src/networking/messages"
# Replaces the long-gone code/framework/src/networking/messages: replication
# is where the sync flow lives now.
"code/framework/src/networking/replication"
"code/framework/src/networking/rpc"
# MafiaNet is fetched, not vendored, so its headers are not in this tree and a
# wire break cannot be detected by path. The pin is the proxy: message ids are
# positional, so moving MafiaNet can shift every id and break every peer built
# against the old header.
"cmake/MafiaNetPin.cmake"
)
minor_paths=(
"code/framework/src/scripting/builtins"
Expand All @@ -38,7 +47,10 @@ minor_paths=(
# Check for major bump directories
for file in "${changed_files[@]}"; do
for major in "${major_paths[@]}"; do
if [[ "$file" == "$major"* ]]; then
# Exact file match, or anything genuinely beneath a directory entry. A bare
# "$major"* prefix would also fire on a sibling that merely starts with the
# same characters -- cmake/MafiaNetPin.cmake.bak, or .../replication2/foo.
if [[ "$file" == "$major" || "$file" == "$major/"* ]]; then
BUMP_TYPE="major"
break 2
fi
Expand All @@ -49,7 +61,7 @@ done
if [[ "$BUMP_TYPE" != "major" ]]; then
for file in "${changed_files[@]}"; do
for minor in "${minor_paths[@]}"; do
if [[ "$file" == "$minor"* ]]; then
if [[ "$file" == "$minor" || "$file" == "$minor/"* ]]; then
BUMP_TYPE="minor"
break 2
fi
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Or use Visual Studio 2022 with CMake tools installed and open the repository fol
4. **Scripting** (`scripting/`) - JavaScript/TypeScript scripting for game logic (Server: libnode, Client: V8)
5. **GUI Manager** (`gui/manager.h`) - UI using CEF and Dear ImGui
6. **Job System** (`jobs/job_system.h`) - Opt-in fiber-based task scheduling using FTL
7. **Voice** (`voice/`) - Proximity voice chat; the server relays opaque Opus frames (RakVoice) without decoding, routed by `VoiceRouter`

### Integration Layer

Expand Down
27 changes: 27 additions & 0 deletions cmake/MafiaNetPin.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# MafiaNet dependency pin.
#
# Deliberately its own file rather than a line inside vendors/CMakeLists.txt.
# MafiaNet's message-id enum is positional: inserting an id shifts every id after
# it and breaks every peer built against the old header. Framework's release
# tooling classifies a version bump by which paths a change touches
# (.github/bump_version.sh), so the pin needs a path of its own that means
# exactly one thing -- "the wire format may have moved" -- instead of being
# buried among unrelated vendor edits.
#
# Raising this pin across a MAJOR or MINOR MafiaNet version is a breaking change
# for the Framework too, and bump_version.sh treats a change to this file as a
# major bump on that basis. A PATCH-only bump is wire-compatible by MafiaNet's own
# versioning, but still lands here so the pin has a single home.
#
# Note it must NOT live under vendors/: .gitignore carries `vendors/**/*.cmake`,
# which would silently exclude it from the repository.

# Pinned by commit, not by tag. A tag is a mutable ref -- repointing it would
# change what every future build fetches while this file still reads the same --
# whereas a commit is what it is. Keep the human-readable release beside it.
#
# Plain set(), not a CACHE entry: a cached value survives in an existing build
# tree, so bumping the pin here and rebuilding incrementally would silently keep
# fetching the old revision. This file is the single source of truth, and there is
# no reason to let -D override the wire format of the protocol.
set(MAFIANET_PIN "a515c827e01868ecc6be26ee82e4da7a04955f77") # v0.13.0
8 changes: 6 additions & 2 deletions code/framework/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ set(FRAMEWORK_SERVER_SRC

# JavaScript scripting (server module)
src/integrations/server/scripting/module.cpp

src/voice/server/voice_router.cpp
src/voice/server/voice_server.cpp
)

set(FRAMEWORK_CLIENT_SRC
Expand Down Expand Up @@ -93,6 +96,8 @@ set(FRAMEWORK_CLIENT_SRC
src/integrations/client/scripting/builtins/keybinds.cpp
src/integrations/client/scripting/builtins/chat.cpp
src/integrations/client/scripting/builtins/discord.cpp

src/voice/client/mixer.cpp
)

# GUI (CEF-based)
Expand Down Expand Up @@ -195,7 +200,6 @@ macro(link_shared_deps target_name)
${CMAKE_SOURCE_DIR}/vendors/spdlog/include
${CMAKE_SOURCE_DIR}/vendors/fmt/include
${CMAKE_SOURCE_DIR}/vendors/fu2 # function2 (used in network_peer.h)
${CMAKE_SOURCE_DIR}/vendors/mafianet/Source/include # Networking / MafiaNet (used in connection.h)
${CMAKE_SOURCE_DIR}/vendors/cxxopts # Command-line parsing (used in integrations)
${CMAKE_SOURCE_DIR}/vendors # sentry, etc.
)
Expand All @@ -221,7 +225,7 @@ macro(link_shared_deps target_name)
endif()

# Global libraries (v8/v8pp excluded - linked explicitly to scripting targets only)
target_link_libraries(${target_name} MafiaNet glm spdlog cppfs nlohmann_json Sentry httplib OpenSSL::SSL OpenSSL::Crypto Curl semver Hash ftl Tracy::TracyClient)
target_link_libraries(${target_name} MafiaNet::MafiaNetStatic glm spdlog cppfs nlohmann_json Sentry httplib OpenSSL::SSL OpenSSL::Crypto Curl semver Hash ftl Tracy::TracyClient)

# Required libraries for windows
if(WIN32)
Expand Down
15 changes: 15 additions & 0 deletions code/framework/src/core_modules.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ namespace Framework::Networking::Replication {
} // namespace Framework::Networking::Replication


namespace Framework::Voice {
class VoiceServer;
} // namespace Framework::Voice

namespace Framework::GUI {
class Manager;
} // namespace Framework::GUI
Expand Down Expand Up @@ -48,6 +52,7 @@ namespace Framework {
static void Reset() noexcept {
_networkPeer = nullptr;
_replication = nullptr;
_voiceServer = nullptr;
_scriptingModule = nullptr;
_webManager = nullptr;
_input = nullptr;
Expand All @@ -66,6 +71,11 @@ namespace Framework {
_replication = replication;
}

static void SetVoiceServer(Voice::VoiceServer *voice) {
FW_ASSERT_MODULE_REGISTRATION(_voiceServer, voice, "VoiceServer");
_voiceServer = voice;
}

static void SetScriptingModule(Scripting::ScriptingModule *module) {
FW_ASSERT_MODULE_REGISTRATION(_scriptingModule, module, "ScriptingModule");
_scriptingModule = module;
Expand Down Expand Up @@ -99,6 +109,10 @@ namespace Framework {
return _replication;
}

static Voice::VoiceServer *GetVoiceServer() noexcept {
return _voiceServer;
}

static Scripting::ScriptingModule *GetScriptingModule() noexcept {
return _scriptingModule;
}
Expand All @@ -122,6 +136,7 @@ namespace Framework {
private:
static inline Networking::NetworkPeer *_networkPeer {};
static inline Networking::Replication::ReplicationManager *_replication {};
static inline Voice::VoiceServer *_voiceServer {};
static inline Scripting::ScriptingModule *_scriptingModule {};
static inline GUI::Manager *_webManager {};
static inline Input::IInput *_input {};
Expand Down
45 changes: 45 additions & 0 deletions code/framework/src/integrations/server/instance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ namespace Framework::Integrations::Server {
});
}

// Voice relay: attaches RakVoice to the live peer, so it must come up after the networking
// engine. Failure is not fatal — the server simply runs without voice.
if (_voiceServer.Init(_networkingEngine->GetNetworkServer())) {
CoreModules::SetVoiceServer(&_voiceServer);
}
else {
Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Voice relay unavailable; voice chat disabled");
}

if (!_opts.bindPublicServer || !_masterlist->Init(_opts.services.masterlistUrl, _opts.bindSecretKey)) {
Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Server will not be announced to masterlist");
}
Expand Down Expand Up @@ -349,6 +358,9 @@ namespace Framework::Integrations::Server {
Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Disconnecting peer {}, reason: {}", guid.g, static_cast<uint32_t>(reason));
_armedSpawnBarrierGuids.erase(guid.g);
_readyPlayerGuids.erase(guid.g);
// Drop voice state unconditionally: a peer that never reached the ready barrier can
// still have been registered in the router by a position push.
_voiceServer.OnPlayerDisconnect(guid.g);

// Player notification and avatar teardown run in ReplicationManager::OnClosedConnection,
// which RakNet fires before this packet is delivered; here we just finalise the connection.
Expand Down Expand Up @@ -453,6 +465,20 @@ namespace Framework::Integrations::Server {
OnClientEvent(sender->GetNetworkID(), name, payload.GetPayload());
});

// Voice frames are not RPCs: RakVoice writes a raw message id, so they surface on the
// unknown-packet path (the relay host deliberately declines to consume them itself).
net->SetUnknownPacketHandler([this, net](MafiaNet::Packet *packet) {
// GetPacketDataOffset() is the offset the peer resolved for this very packet, so an
// ID_TIMESTAMP prefix is already skipped.
const int offset = net->GetPacketDataOffset();
if (offset < 0 || static_cast<uint32_t>(offset) >= packet->length) {
return;
}
if (packet->data[offset] == ID_RAKVOICE_RELAY_DATA) {
_voiceServer.OnVoiceFrame(packet);
}
});

Logging::GetLogger(FRAMEWORK_INNER_SERVER)->debug("Networking messages registered");
}

Expand Down Expand Up @@ -865,6 +891,9 @@ namespace Framework::Integrations::Server {
_scriptingModule->PreShutdown();
}

// Detach from the peer before the networking engine tears it down.
_voiceServer.Shutdown();

if (_networkingEngine) {
_networkingEngine->Shutdown();
}
Expand All @@ -891,6 +920,7 @@ namespace Framework::Integrations::Server {

CoreModules::SetNetworkPeer(nullptr);
CoreModules::SetReplication(nullptr);
CoreModules::SetVoiceServer(nullptr);
CoreModules::SetScriptingModule(nullptr);
CoreModules::Reset();

Expand All @@ -907,6 +937,21 @@ namespace Framework::Integrations::Server {
_networkingEngine->Update();
}

// Refresh the voice router's world view from the replicated entities. Every entity
// carrying an owner GUID is a player-controlled one, which is exactly the set the
// proximity rule keys on; ForEachEntity avoids the per-entity dynamic_cast that
// ForEach<NetworkEntity> would cost for no added selectivity.
if (auto *replication = _networkingEngine ? _networkingEngine->GetNetworkServer()->GetReplicationManager() : nullptr) {
FW_PROFILE_SCOPE_N("Server::VoicePositions");
auto &router = _voiceServer.GetRouter();
replication->ForEachEntity([&router](Framework::Networking::Replication::NetworkEntity *entity) {
if (entity->ownerGUID != MafiaNet::UNASSIGNED_PEER_GUID) {
router.SetPlayerPosition(static_cast<uint64_t>(entity->ownerGUID), entity->position);
}
});
_voiceServer.Update();
}

if (_scriptingModule) {
FW_PROFILE_SCOPE_N("Server::Scripting");
_scriptingModule->Update();
Expand Down
4 changes: 4 additions & 0 deletions code/framework/src/integrations/server/instance.h
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include "logging/logger.h"
#include "networking/engine.h"
#include "scripting/module.h"
#include "voice/server/voice_server.h"

#include <external/sentry/wrapper.h>

Expand Down Expand Up @@ -144,6 +145,9 @@ namespace Framework::Integrations::Server {
std::unique_ptr<Utils::CommandListener> _commandListener;
std::unique_ptr<Utils::CommandProcessor> _commandProcessor;
std::unique_ptr<External::Sentry::Wrapper> _crashReporter;
// Proximity voice relay. Value member: it holds no resources until Init attaches it to
// the peer, so a mod that never enables voice pays nothing beyond the empty maps.
Voice::VoiceServer _voiceServer;
std::unordered_set<uint64_t> _armedSpawnBarrierGuids;
std::unordered_set<uint64_t> _readyPlayerGuids;

Expand Down
76 changes: 76 additions & 0 deletions code/framework/src/voice/client/mixer.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* MafiaHub OSS license
* Copyright (c) 2026, MafiaHub. All rights reserved.
*
* This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework.
* See LICENSE file in the source repository for information regarding licensing.
*/

#include "mixer.h"

#include <algorithm>
#include <cmath>

namespace Framework::Voice {
namespace {
constexpr float kInt16Scale = 1.0f / 32768.0f;

// Below this distance a speaker is at full volume; attenuation starts beyond it.
// Without it, a speaker standing on top of the listener produces a division blow-up
// and an unpleasant volume spike as they cross the origin.
constexpr float kMinDistance = 1.0f;

// How far the pan is allowed to swing. A full hard pan sounds wrong on headphones
// for a speaker only slightly off-axis, so the effect is deliberately partial.
constexpr float kMaxPan = 0.6f;
} // namespace

SpeakerGain ComputeGain(const ListenerTransform &listener, const glm::vec3 &speakerPos, float range) {
SpeakerGain gain;

const glm::vec3 delta = speakerPos - listener.position;
const float distance = glm::length(delta);

if (range <= 0.0f || distance > range) {
return gain; // silent
}

// Inverse-distance rolloff, normalised so it reaches zero exactly at `range` rather
// than trailing off asymptotically and leaving a faint always-audible tail.
const float clamped = std::max(distance, kMinDistance);
const float rolloff = kMinDistance / clamped;
const float edgeFade = 1.0f - (distance / range);
const float attenuation = std::clamp(rolloff * edgeFade, 0.0f, 1.0f);

// Pan on the listener's right axis. cross(up, forward) — not cross(forward, up),
// which yields the left axis in a right-handed system and inverts the whole pan.
// Degenerate transforms fall back to centred.
float pan = 0.0f;
if (distance > 0.0001f) {
const glm::vec3 right = glm::cross(listener.up, listener.forward);
const float rightLen = glm::length(right);
if (rightLen > 0.0001f) {
pan = glm::dot(delta / distance, right / rightLen) * kMaxPan;
}
}

// Constant-power pan: gains follow a quarter-circle so total energy stays flat as a
// speaker sweeps across, instead of dipping in the middle as linear panning does.
// A centred speaker therefore sits at cos(45 degrees) = ~0.707 per ear, not 1.0 —
// that is the property that keeps perceived loudness constant, so it is not
// normalised away.
const float angle = (pan + 1.0f) * 0.25f * 3.14159265358979323846f;

gain.left = std::clamp(attenuation * std::cos(angle), 0.0f, 1.0f);
gain.right = std::clamp(attenuation * std::sin(angle), 0.0f, 1.0f);
return gain;
}

void MixFrameInto(float *stereoOut, const int16_t *monoIn, uint32_t samples, SpeakerGain gain) {
for (uint32_t i = 0; i < samples; i++) {
const float sample = static_cast<float>(monoIn[i]) * kInt16Scale;
stereoOut[i * 2] += sample * gain.left;
stereoOut[i * 2 + 1] += sample * gain.right;
}
}
} // namespace Framework::Voice
38 changes: 38 additions & 0 deletions code/framework/src/voice/client/mixer.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* MafiaHub OSS license
* Copyright (c) 2026, MafiaHub. All rights reserved.
*
* This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework.
* See LICENSE file in the source repository for information regarding licensing.
*/

#pragma once

#include <glm/glm.hpp>

#include <cstdint>

namespace Framework::Voice {
// Where the local player is listening from. Published by the game each frame; consumed
// by the audio thread through an atomically swapped snapshot.
struct ListenerTransform {
glm::vec3 position {0.0f};
glm::vec3 forward {0.0f, 0.0f, 1.0f};
glm::vec3 up {0.0f, 1.0f, 0.0f};
};

// Per-ear linear gain for one speaker, in [0, 1].
struct SpeakerGain {
float left = 0.0f;
float right = 0.0f;
};

// Distance attenuation and constant-power stereo pan for one speaker relative to the
// listener. Returns silence beyond `range`. Pure: no state, safe on the audio thread.
SpeakerGain ComputeGain(const ListenerTransform &listener, const glm::vec3 &speakerPos, float range);

// Accumulates `samples` mono int16 samples into an interleaved stereo float buffer,
// applying `gain`. Adds rather than assigns so several speakers can be layered.
// `stereoOut` must hold at least samples * 2 floats.
void MixFrameInto(float *stereoOut, const int16_t *monoIn, uint32_t samples, SpeakerGain gain);
} // namespace Framework::Voice
Loading