SOFLAM Integration Core
Who may send Changed
The bug that took the longest: with a dedicated server, no laser and no aiming worked, and not a single error appeared. The server simply never heard the client.
The rule
An Rpc addressed to the server is delivered only if the sending client owns the entity the component sits
on. If it does not, the message is dropped – no error, no log line. The engine's routing table says so (RplDocs.c, “RPC invoke from the client”: Not Owner → Dropped). In single-player nothing shows, because client
and server are one machine and the question of ownership is never asked.
The log of the first real server test looked like this on the client, from the moment the laser was switched on (shortened from the comment in GTG_SOFLAM_Relay.c; the Core prints its messages in German, translation below):
(W) Designator sendet ohne Besitz der Traegerentitaet -- der Server verwirft diese Nachrichten
[NETZ] SENDE Ziel anlegen | Rolle Proxy-ohne-Besitz | Laser true
[NETZ] SENDE Ziel aktualisieren | Rolle Proxy-ohne-Besitz | Laser true
(W) Kein Strahl seit einer Sekunde: kein Ziel unter dieser Ersteller-Id
[NETZ] SENDE Ziel aktualisieren (weiter im Sekundentakt)
(not a single "WIRKT" line - "takes effect" - after switch-on)
Translation: “Designator sends without owning the carrier entity – the server drops these messages”; “SENDE” is send, “Rolle Proxy-ohne-Besitz” is role proxy without owner; “no beam for one second: no target under this creator id”. The proof that the way back was fine stood three minutes earlier in the same log: a “WIRKT Ziel entfernen” arrived with no “SENDE” before it – so it came over the network. Only the direction client → server failed.
A relay on the character
You need an entity the player owns. The character is always his. Vanilla already hangs server-bound methods
on the character controller component (SCR_CharacterControllerComponent carries four RplRcver.Server methods), so the route is not just allowed, it is the one in use. The
SCR_PlayerController itself carries no RPC of its own in vanilla; reaching it would need a prefab override, which is a load-order lottery with every other mod that does the same.
So the Core adds eight server-bound methods to the character controller (GTG_SOFLAM_Relais*) and a public method for each that sends it (GTG_SOFLAM_Sende*). Every pair is
the same shape. The designator picks the route per message:
modded class SCR_CharacterControllerComponent
{
// runs on the SERVER, with an id that came from a client - so with every check that belongs to that
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
void GTG_SOFLAM_RelaisZielAktualisieren(RplId geraet, string code, vector position)
{
GTG_SOFLAM_LaserDesignatorComponent designator = GTG_SOFLAM_Relay.FindeGeraet(geraet);
if (designator)
designator.GTG_ServerZielAktualisieren(code, position);
}
// Rpc() is protected: the call must stand inside THIS class, so the designator calls this public method
void GTG_SOFLAM_SendeZielAktualisieren(RplId geraet, string code, vector position)
{
Rpc(GTG_SOFLAM_RelaisZielAktualisieren, geraet, code, position);
}
}
// in the designator
protected bool GTG_BrauchtRelais()
{
if (!m_RplComponent)
return false;
if (Replication.IsServer())
return false; // the server sends directly
return !m_RplComponent.IsOwner(); // an owner sends directly, anybody else needs the relay
}
SCR_CharacterControllerComponent relais;
if (GTG_BrauchtRelais())
relais = GTG_SOFLAM_Relay.Sender();
if (relais)
relais.GTG_SOFLAM_SendeZielAktualisieren(GetCreatorId(), m_sLaserCode, targetPos);
else
Rpc(GTG_RpcAsk_UpdateTarget, m_sLaserCode, targetPos);
If the relay is not available, the message goes the direct way anyway. It is lost then, but the behaviour is the same as before and the one-time warning below names the reason. A silent special route would be the worse of two bad options.
| Relay method (server-bound) | Channel | Carries |
|---|---|---|
GTG_SOFLAM_RelaisZielAnlegen | Reliable | create the target (device id, code) |
GTG_SOFLAM_RelaisZielAktualisieren | Reliable | position and code. Unreliable until 2026-09-17, then changed: entries stood for minutes as corpses. |
GTG_SOFLAM_RelaisZielEntfernen | Reliable | remove the target |
GTG_SOFLAM_RelaisCode, GTG_SOFLAM_RelaisStaerke | Reliable | laser code, laser power |
GTG_SOFLAM_RelaisZielen | Unreliable | aim angles of the ball head. A lost angle heals itself on the next tick; reliability would only build a queue. |
GTG_SOFLAM_RelaisZurueck | Reliable | put the device back on the mount |
GTG_SOFLAM_RelaisVerfolgung | Reliable | tracking on/off with target id and local point. A lost on/off flip stays wrong; there is no next tick that heals it. |
Every send looks the sender up again from the controlled entity (GTG_SOFLAM_Relay.Sender()), never from a remembered reference: the character changes at a respawn, and an old reference points at a corpse the player no longer owns.
On the server, FindeGeraet turns the device's RplId into a designator – with GTG_SOFLAM_LaserDesignatorComponent.FindOn, because a search for the base class does not find the subclass on the SOFLAM.
When there is neither ownership nor a relay, the designator says so once, so that the laser does not just silently not work:
[GTG_SOFLAM] Designator sendet ohne Besitz der Traegerentitaet UND ohne erreichbares Relais -- der Server verwirft diese Nachrichten
(RplDocs: RPC invoke from the client, Not Owner -> Dropped). GTG_SOFLAM_Relay.Sender() liefert nichts: kein PlayerController oder kein gesteuerter Charakter.
RPCs live in your own component
An earlier approach put all four RPC blocks into modded class SCR_ChimeraCharacter. Everybody who
loaded the mod inherited that extension unasked. A component becomes a replication item simply by having
[RplRpc] methods. The second gain: the broadcast lands on the same component on every machine, so
the creator id does not have to travel with every packet – each machine derives it from its own carrier entity (GetCreatorId() is the id of the replication component). Only the relay, which sits on the character, has to carry the device's id.
Ask, Do, Apply: the receive path must not send
Every state change has three methods: Ask (client to server), Do (broadcast) and Apply (the effect). The server applies a change itself and then broadcasts (a broadcast does not deliver to its own sender). If the method that applies a received state also reported that state, a client that receives a broadcast would immediately send it back to the server, and the loop closes. A commented-out line used to hide exactly that. The public path reports, the receive path applies and stays silent.
[RplRpc(RplChannel.Reliable, RplRcver.Server)]
protected void GTG_RpcAsk_UpdateTarget(string code, vector position)
{
GTG_ServerZielAktualisieren(code, position);
}
void GTG_ServerZielAktualisieren(string code, vector position)
{
GTG_ApplyUpdateTarget(code, position); // the server's own copy
Rpc(GTG_RpcDo_UpdateTarget, code, position); // everybody else
}
[RplRpc(RplChannel.Reliable, RplRcver.Broadcast)]
protected void GTG_RpcDo_UpdateTarget(string code, vector position)
{
GTG_ApplyUpdateTarget(code, position); // never sends
}
The server-side entry points (GTG_ServerZielAnlegen, GTG_ServerZielAktualisieren, GTG_ServerZielEntfernen, GTG_ServerCode, GTG_ServerStaerke) are public on purpose: the relay calls them from the outside.
The seeker uses the same split (GTG_RpcAsk_SetState / GTG_RpcDo_SetState / GTG_ApplySearchState).
Late joiners
State that arrives only as messages is gone for everybody who connects later. The designator implements RplSave and
RplLoad for the state that matters – laser on or off, code, last sent position, laser power – or a late client
sees nothing until the next report. On loading, a running laser re-enters the registry through the same GTG_ApplyUpdateTarget.
When you can see the problem
The Core has a switch for exactly this, GTG_LOG_NETZ (off in the published version). It prints one line per send and per effect with the role of the machine:
[GTG_SOFLAM][NETZ] SENDE <message> | Rolle <Server|Besitzer|Proxy-ohne-Besitz> | Code ... | Laser ... and ... WIRKT <message> | .... A SENDE that is never followed by a WIRKT on the server was dropped;
a WIRKT without a SENDE on the same machine came over the network. Put a tag on every line of your own mod and write the role into every network line.