Saved 2026-08-08 (before a PC restart). Read this before continuing.
Architecture: hybrid/thin-client reconciliation over a single consolidated TCP connection (7778). The host streams every ship in each client's sector; the client binds them to its local ships and pins them to the host's interpolated position.
Simulation mode (client) — THIN-CLIENT is now the STANDARD:
X4MP_INERT=1(default): ships are inert + driven purely by the host stream (no local AI). Smoothest sync — the highway flicker is gone.X4MP_INERT=0: HYBRID — the local sim keeps running for ships and is reconciled to the host (can flicker).- Chosen in the launcher ("Simulation mode"), thin-client = option 1 (standard).
Glide convergence (fixes the highway sector-entry teleport): on sector entry a
bound ship diverged <= X4MP_GLIDE_MAX (20 km) glides in at X4MP_GLIDE_SPEED
(1500 m/s) instead of teleporting; far (offscreen) ships snap. g_missing_prune_ms
is 30 s (aligned with the ghost stale threshold).
Features (thin-client build):
- ✅ Core sync: player position, ships, other players (ghosts), camera snap — flicker-free.
- ✅ Stations both directions (host→client
STA, client→hostACT BUILD). - 🟡 Trading cargo (
CARGO) — deployed, needs in-game testing. (Credits not synced.) - 🟡 Combat kills + player death (
KILL/PLAYERDIED) — deployed, needs testing. - 🟡 Boarding: the player can board + capture (local); the capture result
propagates (
ACT CAPTURE/CAPTURE). Thin-client inert exemption (BoardingOperationStarted/RemovedMD events) lets the local boarding run. The boarding marines/phase animation is NOT recreated on other clients. - ⚪ Not implemented: shot/projectile replication, trading credits, full boarding
operation on peers (all need Linux SDK RE or host-side
CreateBoardingOperation).
Deployment: source of truth is this machine (192.168.1.101). Build locally,
scp .so + x4mp_run.sh/launcher to 192.168.1.16. Both machines run the game
with -showfps -nocputhrottle. Current md5s: x4mp.so
285ab3aa0a415623fddf9dfb1c9c070a (2026-08-23 sector-guard log-flood fix),
x4mp_stream.so 413d92fbd18a9df5b74b6aa5ef28e2e6 (2026-08-23 ghost rendering +
glibc 2.38 + diagnostics). Both extensions now require only glibc ≥ 2.38
(Ubuntu 24.04 works). Region streaming is opt-in (X4MP_REGION=1, X4MP_REGION_M),
off by default.
Anti-flicker rendering (client) — NEW (2026-08-22/23): the launcher's
"Anti-flicker mode" menu (client) offers three strategies. Diagnosis proved the
client and host share one coordinate frame (no mismatch) but their ship
populations diverge (client frozen at save positions vs host simulated), so
macro+nearest binding mis-binds and pins fail. Ghost rendering (X4MP_GHOSTS=1)
is the recommended standard: the client suppresses its diverged local ships on
sector entry and renders the host's world purely as ghosts — eliminating
wrong-binding flicker, the inert tug-of-war, and divergence. Pin + glide
(X4MP_PIN_INTERVAL=1) and original remain as alternatives. Verified: with
ghost mode, [FLK] drift=0 pin=0 and [FRM] local_mind=-1 (n=0).
To test the 🟡 items: board a ship with cargo + trade (trading), kill a ship (combat), board + capture a ship (boarding).
Turn X4: Foundations (singleplayer) into a LAN multiplayer setup:
- Machine 2 (192.168.1.16) runs as HOST (server).
- Machine 1 (192.168.1.101) runs as CLIENT and joins by IP.
- Both machines render a window (menu-driven host/join).
- Uses the game's exported functions
NewMultiplayerGame()/ConnectToMultiplayerGame()(RakNet transport, no Steam needed — no-Steam dev file is used). - Bridge is the
x4nativeC++ extension (loaded by the game) +x4mpextension.
The game crashed (SIGSEGV) whenever NewMultiplayerGame was called, and whenever
the resolution was changed. Root cause: a cross-instance race in the x4native
proxy (libx4native_64.so). Multiple proxy instances were all
copy_file()-ing to the SAME x4native_core_live.so path concurrently,
corrupting the file before dlopen/dlsym — crashing when a worker thread
("Movement worker", spawned by NewMultiplayerGame or a resolution change)
re-entered load_core().
Fix applied in X4-C++-Extension-Linux/source-code/X4Native/src/proxy/proxy.cpp:
load_core()nowdlopen()s the ORIGINALx4native_core.sodirectly (no copy to a shared live path).core_needs_reload()now returnsfalse(no live copy to compare).- Added a
std::recursive_mutexaround core load/reload/destructor (defensive).
This fixed BOTH crashes. Verified: game stays alive after NewMultiplayerGame,
host socket stays active, resolution changes no longer crash.
Deployed: X4 Foundations/extensions/x4native/native/libx4native_64.so
(backup of the old one kept as libx4native_64.so.bak).
- The shim (
libshim/vkshim.c, LD_PRELOAD) andVK_ICD_FILENAMES(forcing radv) are NOT needed for normal rendering. The game works fine with its default Vulkan driver. The launch scripts still load the shim + force radv — cleanup pending. - The game's built-in Lua has NO
os.getenv(os.getenv = nil). Lua cannot read env vars. Only the C++ extension can read env vars. (This is why the Lua-side auto-host attempt failed.) - The game loads saves via the Lua global
LoadGame(filename)or the C functionContinueGameStart(). The game also has aloadSaveLua event that callsLoadGame. Save files live in~/.config/EgoSoft/X4/save/(e.g.save_010.xml.gz). - Valid gamestart IDs are in
libraries/gamestarts.xml(e.g.x4ep1_gamestart_boron1,x4ep1_gamestart_terran1, ...).
| File | State |
|---|---|
X4-C++-Extension-Linux/.../src/proxy/proxy.cpp |
MODIFIED (crash fix). Rebuilt + deployed. |
X4-C++-Extension-Linux/.../examples/x4mp/x4mp.cpp |
MODIFIED (reads X4MP_SAVE, prevents auto-host when save set). Rebuilt + deployed. Save-loading itself NOT yet implemented in C++. |
X4 Foundations/extensions/x4mp/ui/x4mp_menu.lua |
REVERTED to clean (menu buttons only). No auto-host logic (os.getenv doesn't work). |
x4mp_launcher.sh |
NEW interactive launcher (role / IP / gamestart / save). Works. |
test_minimal.sh |
NEW test launcher (no shim, no radv, auto-host). |
dedicated_server.sh / connect_client.sh |
CLEANED: shim (LD_PRELOAD) and VK_ICD_FILENAMES removed. Use default Vulkan driver. |
Save loading for multiplayer is implemented and verified working.
Flow implemented in x4mp.cpp:
- Reads
X4MP_SAVEintog_save. - When
X4MP_AUTO=hostandX4MP_SAVEis set:- Waits until
g_game->IsSaveListLoadingComplete()returns true (game/save list ready). - Raises the game's
loadSaveLua event viaapi->raise_lua_event("loadSave", g_save.c_str())(this callsLoadGame). - Subscribes to
on_game_loaded; when it fires, callsdo_host().
- Waits until
CRITICAL: the save filename must be WITHOUT the .xml.gz extension
(e.g. save_010, NOT save_010.xml.gz). C.IsSaveValid() rejects the extension.
The launcher now strips the extension automatically.
Verified in log: save list ready → game loaded → "save loaded — hosting now" →
NewMultiplayerGame invoked → host socket active → heartbeat HOST active.
Deployed: X4 Foundations/extensions/x4mp/native/x4mp.so (rebuilt).
x4mp_launcher.sh updated to strip the extension.
# x4native proxy (crash fix already applied)
cd /home/lunarbuntu/Programming/x4-pack/X4-C++-Extension-Linux/source-code/X4Native
cmake --build build
cp native/libx4native_64.so "/home/lunarbuntu/Programming/x4-pack/X4 Foundations/extensions/x4native/native/"
# x4mp extension
cd /home/lunarbuntu/Programming/x4-pack/X4-C++-Extension-Linux/source-code/X4Native/examples/x4mp
cmake --build build
cp build/x4mp.so "/home/lunarbuntu/Programming/x4-pack/X4 Foundations/extensions/x4mp/native/"Problem: client called ConnectToMultiplayerGame at extension init (too early),
before the game reached the start menu -> "Failed to initialize the network engine".
It never retried.
Fix (deployed): client now waits 20s (game reaches menu at ~14s) before the first
join, then retries every ~5s until on_game_loaded fires (client loaded host universe)
or 240 attempts (~20 min). Uses wall-clock delay (NOT IsSaveListLoadingComplete,
which returns true immediately at init and is NOT a menu-ready signal).
Verified: client waits exactly 20s (init 10:55:24 -> first join 10:55:44).
Scanning 192.168.1.16 from Machine 1: host pings (0.24ms) but NO ports are open (6000-6100, 7777, 2302, 27015, 9000-50000 all closed). The host is NOT actually hosting / the RakNet socket is NOT bound. Likely causes:
- Machine 2 has the OLD x4mp.so (no save-loading fix) -> with a save selected, nothing hosts (old code waited for Lua that never fired).
- OR the host process isn't running / crashed.
To verify/fix host: deploy the LATEST x4mp.so to Machine 2 (see deploy script), start the host, and check the host log for: "x4mp: NewMultiplayerGame invoked; RakNet host socket active" "x4mp: heartbeat — HOST active" Then re-scan ports from Machine 1 to confirm a socket is bound.
DECISION: the built-in SLNet multiplayer does NOT work (its network engine fails with "Failed to initialize the network engine (mode 1/2)" — needs Steam). So we wrote our OWN UDP netcode in x4mp.cpp, completely independent of SLNet.
Architecture (authoritative server -> thin client): HOST runs the ENTIRE universe simulation and streams snapshots to clients. CLIENT does NOT simulate the universe; it only syncs with the host and renders what it needs. Goal: offload universe calculation from client to server.
Layer 1 (transport) is DONE and VERIFIED WORKING:
- Host binds a raw UDP socket on X4MP_PORT (default 7777).
- Client connects to host IP:port, sends JOIN, host replies WELCOME.
- VERIFIED: host log "net: HOST listening on UDP port 7777"; client log "net: CONNECTED to host (handshake OK)" (repeated as the game reloads the extension during startup — expected).
- Code: custom UDP in x4mp.cpp (net_init_host/client, net_poll, net_update).
IMPORTANT environment findings (Machine 2 = VM):
- Xvfb does NOT work: radv can't present on Xvfb (no DRI3) -> game hangs (processes stay ~30-40MB, never load). MUST use the real Xwayland display :0.
- The XAUTHORITY for :0 is at /run/user/1000/.mutter-Xwaylandauth. (NOT
in HOME). x4mp_run.sh now finds it via
find /run/user/$(id -u). - Game loading can take up to 600s (VM is resource-constrained; the model runs on Machine 1 + Machine 2 over RPC, spiking CPU to 99%).
- Force radv via VK_ICD_FILENAMES (default picks lavapipe which crashes).
VERIFIED bidirectional sync between Machine 1 (client) and Machine 2 (host):
- HOST -> CLIENT: player-state snapshots. Client log: "SNAP id=1272361 pos=(-114194.0, 363.8, 16879.9) rot=(-1.1,0,0) hosttick=12570" "SNAP id=1272361 pos=(131107.1, 363.5, -14239.8) rot=(-1.0,0,0) hosttick=12720" Position is LIVE and CHANGING between snapshots -> real-time state streaming.
- CLIENT -> HOST: input/commands. Host log: "HOST got INPUT from 192.168.1.101: INPUT tick=270"
- Message formats (text, one line): SNAP \n INPUT tick=\n
- Host reads state via game func table: GetPlayerObjectID() + GetObjectPositionInSector(). Sends every 30 frames. Client stores it in g_snap_* (Layer 3 will render it).
CONCERN: repeated extension reloads. The client reconnects every ~20s ("client joined id=28,29,30,31"; client re-gets CONNECTED). The game appears to reload the extension periodically (socket closes+reopens). Sync resumes after each reload, but this interrupts the stream. Investigate why the extension is being reloaded (may stop once the universe is fully loaded).
USER DECISION: Option B — true thin client. Client does NOT simulate the universe; it only syncs with the host and renders what it needs. No time constraints.
Phase 1 DONE — reload loop fixed. Root cause: the client called the built-in
SLNet ConnectToMultiplayerGame, which fails (needs Steam) and the game reloads
the UI every ~20s (re-initializing the extension, closing our socket). FIX: the
client NO LONGER joins via SLNet. Instead it loads the SAME save as the host
(X4MP_SAVE=save_010) to enter a universe, then syncs over our custom network.
Result: client init count dropped from 82+ to 2; client is stable and in universe.
Phase 2 DONE (pause) — simulation offloaded. On game loaded, the client raises Lua event "x4mp.pause" -> x4mp_menu.lua calls Pause(). VERIFIED in log: "X4MP: client simulation PAUSED (Pause() called)". The client no longer simulates (AI/economy/combat run only on the host).
Phase 2 (render) — client renders host state. Host streams SNAP with zone: SNAP Client parses it and calls MovePlayerToSectorPos(zone, pos) each frame (thin_client_render) so the client's player follows the host's position.
CONCERN: client CPU still ~82%. The pause stops SIMULATION, but the client still RENDERS the full universe (it loaded the save) + Machine 1 is shared with the AI model. To truly reduce client load, the client must render ONLY host-streamed objects (not the whole universe) — a Layer 3 refinement.
X4MP_DEBUG=1 enables a continuous streamed-data display (writes to the x4mp log AND stdout/terminal). Shows exactly what data is sent/received: HOST: "x4mp: [DBG] HOST streaming player id=.. zone=.. pos=(..) rot=(..) tick=.." CLIENT: "x4mp: [DBG] CLIENT received host player id=.. zone=.. pos=(..) .. hosttick=.." "x4mp: [DBG] CLIENT applied player pos=(..) zone=.."
SYNC IS REAL (verified). Host streams pos=(-68809.8,5444.4,54420.3) zone=389537; client applies the EXACT same pos+zone. The client follows the host's player.
Loading timing does NOT cause mismatch: the client connects to the host's socket (binds at init, before universe loads) and only receives state once the host's player is ready. The client always applies the host's latest streamed state, so timing is irrelevant.
REAL mismatch = different save files. Machine 1's save_010 != Machine 2's save_010 (different universe content). Client follows host's player but the rest of the world is the client's own save. This is exactly what Option 2 solves.
- DONE: Custom network transport (Layer 1) — see 6d.
- DONE: Sync protocol (Layer 2) — see 6e.
- DONE: Fix reload loop + client enters universe via save — see 6f.
- DONE: Pause client simulation (offload) — see 6f.
- DONE: Debug mode (X4MP_DEBUG=1) + sync verification — see 6g.
- Layer 3 — Option 1 (intermediate): reduce client rendering load (lower view distance / hide distant objects) while it still loads the save.
- Layer 3 — Option 2 (goal): TRUE thin client — client does NOT load the save; renders ONLY host-streamed objects (max offload).
- Deploy to Machine 2: copy the
X4 Foundations/extensions/x4native/andextensions/x4mp/folders + the launcher/scripts to Machine 2.
- Save files:
~/.config/EgoSoft/X4/save/(save_009.xml.gz, save_010.xml.gz). - x4native logs:
~/.config/EgoSoft/X4/x4native/x4native.log. - Game log (when launched via script):
X4 Foundations/server_logs/game.log. - Test log (test_minimal.sh / manual):
X4 Foundations/test_logs/game.log. - The game takes ~20s to load extensions and up to ~180s to load a large save.
- Only ONE X4 instance can run at a time on a machine.
Diagnosis (client-side [FRM]/[FLK]/[FLKV] logs):
- Proved the client and host share one coordinate frame (
[FRM] local_mind≈streamed_mindto the meter; no frame mismatch). The earlier coordinate- offset hypothesis was wrong. - The real cause of residual flicker is population divergence: the client keeps its local universe frozen near save-load positions while the host simulates (ships move/spawn/die). macro+nearest binding then mis-binds (218 of 268 near-player drift outliers were a local ship bound to a host ship ~117 km away), and the pin fails/teleports.
- Phase 1 (per-frame pin,
X4MP_PIN_INTERVAL=1) cut the tug-of-war but could not fix wrong bindings.
Fix — Option A ghost rendering (X4MP_GHOSTS=1, recommended default):
- On sector entry the client now suppresses (RemoveComponent) its diverged local ships (keeping the player's own ship, the load-bearing satellite, stations, and boarding-exempt ships) and renders the host's world purely as ghosts. No binding, no pinning → no wrong-binding flicker, no tug-of-war.
render_passneeded no edits: with no local ships to bind it already falls through to its ghost path, and the prune loop already uses RemoveComponent.- Verified live:
[GHOST] suppressed N local ships,[FLK] drift=0 pin=0,[FRM] local_mind=-1 (n=0) streamed_mind=280m. - Launcher: new client "Anti-flicker mode" menu (1 Ghost [recommended] / 2
Pin+glide / 3 Original).
X4MP_GHOSTS/X4MP_PIN_INTERVALexposed.
Other fixes shipped in this build:
- Host log-flood fix (
x4mp.cpp):sector_of()guard before every activeGetObjectPositionInSectorread — kills theFailed to retrieve sector of object <id>flood (was ~590× for a dying/docked object). - glibc ≥ 2.38 (
x4mp_stream.cpp): a localsqrtfshim replaces thesqrtf@GLIBC_2.43import, so the extension loads on Ubuntu 24.04 (glibc 2.39). README compatibility line corrected. - Launcher GPU fix:
VK_ICD_FILENAMESis only forced to AMDradvwhen no NVIDIA ICD is present (withX4MP_FORCE_RADVoverride) — fixes startup on NVIDIA machines that the old unconditional forcing broke. [FRM]frame diagnostic extended with a per-ship tracer (nearest ship's id/tx/px/pxt) to isolate any residual flicker.
md5s: x4mp.so 285ab3aa0a415623fddf9dfb1c9c070a, x4mp_stream.so
413d92fbd18a9df5b74b6aa5ef28e2e6 (both machines + release).
Open: one nearby ship still flickers visually despite drift=0 — being
tracked with the [FRM] tracer (host-side unstable position vs interpolation
lag vs rendering).
- Client now syncs the host's save (scp in x4mp_run.sh) and LOADS it (paused, no simulation).
- Client receives host OBJ stream (id, zone, sector, pos, macro) and SPAWNS the objects in its OWN player sector.
- KEY FIX 1: sector IDs are runtime-generated and DIFFER between host/client even with same save. So client creates objects in ITS OWN player sector (GetPlayerZoneID→GetContextByClass "sector"), using host's absolute positions (player placed at host pos → relative offsets match).
- KEY FIX 2: SpawnObjectAtPos2 needs a valid ownerid (e.g. "player"), NOT nullptr — nullptr makes it return 0 (silent fail).
- VERIFIED: 382 objects spawned, 0 failures. e.g. "CLIENT spawned host obj id=391621 macro=ship_arg_xs_pv_04_b_macro as client id=1472815 sector=388178".
- Client CPU 145% (rendering), host 216% (simulation). Offload working.
- Debug: X4MP_DEBUG=1 logs "[DBG] CLIENT spawned ..." / "[DBG] CLIENT render_objects: player_zone=.. client_sector=.. objs=..".
- STILL TODO (Option 2): hide client's OWN new-game/save objects so it renders ONLY host-streamed objects → lower client CPU. Currently client renders full universe + spawned host objects.
- Symptom: client crashed with
Speicherzugriffsfehler~2s after the SECONDgame_loadedsignal while loading save_010.coredumpctlshowed:- Signal 11, crashing thread = "Movement worker" (TID 2178750), NOT main.
- Stack entirely in the X4 binary, ending in
std::vector<float>::_M_realloc_append(heap corruption during a vector realloc) — a classic data-race / use-after-free signature.
- Why: X4 loads saves in TWO passes.
on_game_loadedfires after the FIRST pass (entity IDs valid, but universe NOT fully built). Our client marked itself ready on that first signal and started callingSpawnObjectAtPos2/SetObjectSectorPos(thin_client_render_objects) EVERY frame while the game was still in its SECOND pass, rebuilding the universe. Those main-thread calls raced with the game's background Movement worker thread, corrupting the object vectors → SIGSEGV. - The second pass is X4's native save-loading behavior and is NOT optional. The
correct "world ready" signal is
on_universe_ready(fires onevent_universe_generatedAFTER the 2nd pass / all stations built).
- Client no longer marks itself ready on
on_game_loaded. It now waits for the newon_universe_readyhandler (subscribed to the core'son_universe_readyevent, raised via the x4native MD cuex4native.universe_ready).g_client_readyis only set inon_universe_ready, so host objects are only spawned/rendered once the universe is fully built → no race with the Movement worker. - Server:
net_send_objects_host()andnet_refresh_zone_objects()now checkIsValidComponent()and skip/prune stale (destroyed) object IDs. This stops the log flood ofGetContextByClass(): Failed to retrieve component with ID/GetObjectPositionInSector(): Failed to retrieve object with ID.
X4-C++-Extension-Linux/.../examples/x4mp/x4mp.cpp(rebuilt + deployed to clientX4 Foundations/extensions/x4mp/native/x4mp.so).x4mp_run.sh,x4mp_launcher.sh: added X4MP_DEBUG passthrough + docs on the universe_ready gating / stale-ID pruning.- STATE.md (this file).
- CONNECTION STABILITY — the client currently reconnects / the socket is
recreated on every extension reload, and there is no heartbeat/keepalive
timeout or re-sync after a dropped packet. Investigate:
- Why the extension/socket is reloaded periodically (was seen before).
- Add a keepalive + timeout so a dead host is detected and the client retries cleanly instead of hanging.
- Add sequence numbers / ACK for the control channel (JOIN/WELCOME) so a dropped handshake is retried.
- NETWORK PERFORMANCE — the OBJ stream currently sends full text lines for
every object every frame (cache mode) or enumerates all ~85k ships (full
mode). Options:
- Binary protocol (fixed-size structs) instead of ASCII text → ~10x less bandwidth and CPU for parsing.
- Delta compression: only send objects whose position changed.
- Only stream objects in the player's view radius, not the whole zone.
- Rate-limit position updates (e.g. 10-20 Hz) instead of every frame.
- Crash fix VERIFIED: client runs stable (no SIGSEGV) for 12+ min while receiving host OBJ/SNAP data. Before the fix it crashed within ~15s of the second game_loaded (Movement worker SIGSEGV). The client now correctly waits for on_universe_ready (or the X4MP_UNIVERSE_TIMEOUT fallback) before spawning.
- ENVIRONMENTAL ISSUE (Machine 1, not a code bug): the client machine is shared with the AI model (llama-server ~144% CPU) and its disk (nvme1n1) is saturated (~676GB written; X4 alone wrote 23GB). The client's save load / new-game universe generation stalls in wait_dev_flush (D state) for 10+ minutes, so game_loaded/universe_ready never fire in a reasonable time. This blocks full end-to-end render verification on Machine 1. On Machine 2 (host) the same save loads + reaches universe_ready in ~26s.
- Mitigation tried: renice llama-server to priority 15, X4 to -5 (helps CPU but not the disk I/O stall).
- To fully verify end-to-end rendering, run the client on a machine that is not disk/CPU saturated, or pause the AI model during the test.
- New env var: X4MP_UNIVERSE_TIMEOUT (seconds, default 180, 0=strict). Fallback that marks the client ready if on_universe_ready does not fire in time. Only takes effect after game_loaded has fired.
Added to x4mp.cpp (rebuilt + deployed to both machines):
- HOST: tracks per-client last-seen time and PRUNES dead clients after X4MP_HOST_TIMEOUT (default 30s). Verified: "pruned 1 dead client(s); 0 remain". Previously g_clients grew forever and the host streamed to crashed clients.
- HOST: a re-JOIN from an existing client refreshes liveness and re-sends WELCOME (handles client link drops).
- CLIENT: tracks last-received time from the host. If no data for X4MP_CLIENT_TIMEOUT (default 15s), it drops back to unconnected and re-sends JOIN to re-establish the handshake (handles host restart / link drop).
- PING/PONG keepalive: host sends PING every ~5s; client answers PONG.
- New env vars: X4MP_HOST_TIMEOUT, X4MP_CLIENT_TIMEOUT (seconds).
- VERIFIED end-to-end crash fix: client reached game_loaded -> universe_ready (safe path, not fallback) -> spawned host objects with NO crash.
The client machine (Machine 1) is shared with the AI model (llama-server ~146% CPU) and its disk (nvme1n1) is saturated. X4's save load / new-game generation can stall for 10+ min in wait_dev_flush. When the load drops, the client works fine (verified above). For reliable testing, reduce AI-model load or use a less-loaded machine.
Added to x4mp.cpp (rebuilt + deployed to both machines):
- RATE LIMITING: host now streams SNAP/OBJ at a configurable rate (default 15 Hz, X4MP_UPDATE_HZ, 1-60) instead of every frame at 60fps. ~4x less bandwidth/CPU.
- DELTA COMPRESSION: host only re-sends an object if it moved beyond X4MP_DELTA_M meters (default 0.5m), with a periodic forced full refresh. Most objects (stations, idle ships) are not re-sent every tick -> large bandwidth cut.
- New clients force a full object stream (delta state cleared on JOIN) so they receive the complete zone immediately.
- Client INPUT uplink rate-limited to the same update interval.
- New env vars: X4MP_UPDATE_HZ, X4MP_DELTA_M.
- ASCII text protocol: each OBJ line ~100 bytes; 382 objects * 15 Hz ≈ 570 KB/s. A binary fixed-size struct protocol would cut this ~10x and remove sscanf CPU.
- View-radius streaming: only stream objects within the player's view distance instead of the whole zone (biggest possible win for large zones).
- Client-side: only spawn objects once and update via SetObjectSectorPos (already done); avoid re-parsing unchanged data.
- Consider UDP fragmentation: batch is capped at 60000 bytes (near the 65507 UDP max). Fine for now, but a binary protocol would avoid fragmentation.
Addressed the user's request to stream the host's REAL, complete universe state (not just the player's current zone) with no duplicates.
net_refresh_zone_objectsnow enumerates ALL ships across every sector (~85k), not just the player's zone. Positions streamed every frame from the cache with delta compression.- STATIONS are intentionally NOT streamed: the client loads the SAME synced save and keeps its own stations, which preserve sector ownership/claims. Streaming stations would duplicate them on the client.
- Added
sector_macrotoZoneObj; each OBJ line now carries the object's sector MACRO name so the client can place it in the matching local sector. - OBJ format:
OBJ <id> <sectormacro> <x> <y> <z> <yaw> <pitch> <roll> <faction> <macro>(multi-packet batching).
- Added
sector_macrotoRemoteObj,g_sector_map(macro->local sector id), andbuild_sector_map()called on universe_ready. - CRITICAL FIX:
get_macro()must query the Lua APIGetComponentData(id,"macro")(like the host), NOTGetComponentName()which returns DISPLAY names ("Argon Prime") that never match the host's streamed macros ("cluster_113_sector001_macro"). With the wrong lookup the map had only 2 sectors; with the Lua lookup it builds 139 sectors correctly. render_passplaces each object in the mapped local sector (fallback: player sector). Objects spawn in the correct sectors.
- Client sector map: 139 sectors, correct macro names.
- Client spawns host ships across 110+ unique sectors, no SDL error / no crash.
- Host streams all ships; client cleanup removes its own ships (keeps stations).
- No duplicate stations (host doesn't stream them; client keeps its own).
- Client->host simulation actions (shooting, building, trading) are NOT streamed to the host; only the client's PLAYER position is sent. The host's simulation does not reflect client actions.
- Task 3 (pending): refactor platform shims into platform/lin + platform/win.
- Client CRASHED:
AutoIDMap::Insert(): ID map is full-> FATAL exit 1079. The thin-client mode spawned ghosts for the ENTIRE host universe (83k ships x ~30 sub-objects = ~2.5M objects), exhausting the game's ID map. - The host fully simulated only the server player's zone (X4 default); client rendering zones were only "simulated" via the unreliable host-player-teleport hack. User requirement: the high-simulation value must apply to connecting clients too, so each client's own rendering zone is fully simulated and ships behave normally.
Every participant's rendering zone is fully simulated:
- HOST (x4mp.cpp): a HIGH-SIMULATION SECTOR SET = server player's sector
- every connected client's current sector (tracked via PLAYER messages).
net_maintain_universe()(every ~5s) ActivateObject()s all ships in those sectors + places a player-owned satellite in each. Same treatment the server player's zone gets by default, extended to client zones. Sector-macro lookups are deduplicated per pass (~140 Lua calls, not 85k).
- every connected client's current sector (tracked via PLAYER messages).
- CLIENT (x4mp_stream.cpp + x4mp.cpp): the client keeps its OWN locally simulated universe (the synced save — cleanup is OFF by default now). X4 natively fully simulates the client's current sector, so ships in the client's rendering zone behave normally. The client receives ONLY human PLAYER ghosts (host ship cid=0 + other clients), each now carrying its SECTOR MACRO so the ghost is rendered in the correct mapped sector and only when the client is actually in that sector.
- x4mp_stream render_pass now renders ONLY objects in the client's CURRENT sector. OBJ ghosts from other sectors are dropped (memory/ID-bounded). PLAYER ghosts are kept so they reappear when the client flies over.
- Even in thin-client mode (X4MP_STREAMSHIPS=1) the client now spawns only its current sector's ships (~hundreds), not 83k — the ID-map crash cannot recur.
X4MP_STREAMSHIPS0 — 1 = legacy full-universe OBJ broadcast.X4MP_FULLSIM0 (was 1) — 1 = ActivateObject ALL ships (CPU-heavy).X4MP_TELEPORT0 — 1 = legacy host-player-into-client-sector teleport.X4MP_CLEANUP0 (was 1 in x4mp_run.sh) — 1 = remove client's own ships.X4MP_PAUSE0 (unchanged) — client sim runs (that IS the client's high simulation in its rendering zone).- PLAYER wire format:
PLAYER <cid> <x> <y> <z> <yaw> <pitch> <roll> <macro> <faction> <sectormacro>(10th field optional on receive; old 9-field hosts still work).
- x4mp.so (md5 9c37ffd8...) + x4mp_stream.so (md5 a32b8900...) on BOTH machines; sources + launchers synced to 192.168.1.16 (host source tree was stale before this — local is now the source of truth).
- x4mp_run.sh / x4mp_launcher.sh / x4mp_launch_both.sh updated (launch_both no longer forces X4MP_FULLSIM=1; its ship-count wait still works — the "refreshed ALL ships: N" debug line now reports the real ship count).
- Client and host universes DIVERGE over time (client simulates locally, no reconciliation). Next: per-sector host_id<->local_ship binding (macro + proximity) with soft position correction (drift > threshold) so the client's rendering zone tracks the host's truth.
- Client->host actions (shooting/building/trading) still not streamed.
Position pinning fixed STEADY ships, but ships in TRANSITION still flickered.
Signature in the client [FLK] counters during highway flight: zone and
spawn huge and continuous (100-1400 / 100-700 per 15s), while
stale/bindrel/death ~0. Two compounding causes:
- Local-sim divergence: inactive sectors simulate independently of the
host. On re-entry, host ships can't bind to their (moved/docked) local
counterparts within the fixed 20km converge radius -> they are GHOSTED
(
spawn++) instead of bound. (Measured divergences up to ~214-295 km.) - Sector-transition churn: on each jump the old sector's objects are
dropped (
zone++). - Latent host bug: when a client enters a sector the host hasn't indexed yet (maintenance pass pending), the host sent an EMPTY FULL -> the client would prune its whole local sector. (Latent: 10s prune grace > 5s index refresh, but fixed defensively.)
- Phase 1 — persistent AI suppression (X4MP_INERT, now DEFAULT ON):
rebuild_local_index()deactivates (ActivateObject false) EVERY local ship in the sector on entry, so the client's own AI can't dock/move/destroy them and diverge. Bound ships + ghosts are also re-deactivated every pin pass.X4MP_INERT=0restores local AI. - Phase 2 — greedy convergence (X4MP_CONVERGE_GREEDY, default ON): on sector entry (first FULL snapshot), each host ship binds to the NEAREST same-macro local ship with NO distance limit (was 20km). Diverged ships are SNAPPED to the host position (one-time, masked by the sector change) instead of ghosted. After convergence, the tight 1km bind radius applies for re-matching. This is the main transition-flicker fix.
- Phase 3 — binding stability: bindings are keyed by host id and only released when the local ship is truly invalid; prune keeps its 10s grace gated by full-snapshot + link-alive. (Mostly already in place.)
- Phase 4 — smooth transitions: no per-object fade API exists in the SDK, so smoothness comes from the cleaner binding (fewer ghosts, no flapping) + snap-on-entry (masked by the sector view change).
- Host (x4mp.cpp): empty-FULL is now gated on
g_last_index_rebuild >= c.cur_sector_set_time, so a not-yet-indexed sector is never reported empty. - Diagnostics:
[CONVERGE]logs per sector entry (index/host/bound/ghost/ maxbind distance);[FLK]per-15s mechanism rates. Both under X4MP_DEBUG=1. - Test hook (X4MP_AUTOFLY=1, OFF by default): teleports the client player to a sequence of sectors to exercise transitions. NOTE: MovePlayerToSectorPos does NOT change the player zone when the player ship is docked (e.g. at the Prime Hub), so the auto-fly only works once the ship is in space. The sector-change code path is identical to the start-sector convergence, which IS validated.
[CONVERGE] sector=.. index=222 host=214..241 bound=182..196 ghost=18..59 maxbind=214..295km— 75-92% of host ships bound to local ships; diverged ships (up to 295km) snapped, not ghosted. No FATAL / no ID-map crash.[FLK]in the static start system:zone=0, lowspawn/stale(normal trader dock/undock mirrored from the host), no abnormal popping.- Deployed md5: x4mp.so e4e69e7297c1a6fe79fc6af136f08422, x4mp_stream.so cfea0ad586471d989c1be929a0c103d8 (both machines match).
X4MP_INERT1 (was 0) — default ON now.X4MP_CONVERGE_GREEDY1 (new) — greedy (no-radius) entry binding.X4MP_AUTOFLY0 (new) +X4MP_AUTOFLY_INTERVAL10 — test hook.X4MP_CONVERGE_RADIUS(default 20000) is now only used when greedy is OFF.
- User highway validation: fly the highway with host+client; check
[CONVERGE](bound vs ghost) and[FLK](spawn/zone should be far lower than the 100-700 / 100-1400 seen before). The auto-fly can't reproduce this (docked player), so this needs the user driving. - FOLLOW-UP (big): player-action replication. Client actions (shoot/build/
trade/board) -> host executes authoritatively -> broadcast result to all
clients. This is what makes a client-built station (or shot) real for
everyone. NPC-ship sync (this work) and player-action replication are the two
halves of the multiplayer system.
- STARTED: the action transport (client -> host
ACTchannel) is DONE and VALIDATED (host log showsACT test n=...arriving from the client). SeeFOLLOWUP.mdfor the full design (5-step architecture, phased action types A=combat B=building C=trading D=boarding, key challenges, next steps). Env:X4MP_TEST_ACTION=1sends a periodic test ACT (off by default). Deployed md5 (with ACT transport): x4mp.so 4f281a5da6ce129850384ff084a298fc.
- STARTED: the action transport (client -> host
Rewrote the launcher to be friendly for humans. Features:
- Role select (HOST / CLIENT).
- HOST: new gamestart OR load an existing save (lists local saves).
- CLIENT: host IP + save picker. Lists the host's saves over ssh and lets
you pick by number or name. Transfers the save automatically (scp from
the host's
~/.config/EgoSoft/X4/save/); on failure it prints the exact manualscpcommand and warns that a manual transfer is needed. - Extra X4 flags prompt (e.g.
-nocputhrottle -showfps) for both roles. - Logging prompt: on/off + where to store log files.
- Prints a launch summary and confirms before starting.
X4MP_GAME_DIRenv (or place the script next to the "X4 Foundations" folder) so it works regardless of install location. (Applied tox4mp_run.shtoo.)- Bug fixed:
${ARR[$((n-1))].xml.gz}was a bad substitution when picking a save by number (now${ARR[$((n-1))]}+ normalize). ssh/scp stdin is guarded (ssh -n,scp ... < /dev/null) so they don't eat interactive input. - Both host + client flows dry-run tested (abort before launch).
Self-contained package to hand to other testers:
x4mp-release/
├── README.md <- full manual (install + run + config + troubleshooting)
├── install.sh <- copies extensions/ into your game dir (X4MP_GAME_DIR)
├── extensions/ <- x4native + x4mp + x4mp_stream (built .so + UI + xml)
└── scripts/ <- x4mp_launcher.sh (interactive) + x4mp_run.sh (env)
- Install:
X4MP_GAME_DIR="/path/to/X4 Foundations" ./install.sh(orcp -r extensions/* "<game>/extensions/"). - Run host:
X4MP_GAME_DIR=... scripts/x4mp_launcher.sh-> role 1. - Run client: same -> role 2, enter host IP, pick save (auto-transferred).
- The .so files in the package were verified (md5) to match the deployed builds.
x4mp_launch_both.shwas NOT included (it's specific to this two-machine systemd+ssh setup, not general-purpose).
Standardize on TCP (reliability for upcoming stations/boarding/combat) and use ONE port per transport: TCP 7778 (default) or UDP 7777 (secondary), carrying control + data on a single connection. Keep the current working setup (UDP 7777 control + TCP/UDP 7778 data) as a legacy fallback.
- TCP host fps ~31.8, UDP host fps ~28.7 — transport is NOT the FPS cause.
- net_update is only ~3 ms of a ~35 ms frame (~9%). The rest is the game's own simulation (the high-sim: host simulating the client's sector ships).
- x4mp = the networking extension (owns the socket on host AND client).
- x4mp_stream = pure reconciliation; receives OBJ/PLAYER lines via the
x4mp_stream.dataevent (raised by x4mp); keepsrender_passunchanged. - TCP mode: host LISTENS on 7778; client CONNECTS (conventional client->
server, fixes the JOIN chicken-and-egg). Control (JOIN/PLAYER/INPUT/ACT/
WELCOME/PING/SNAP) + data (OBJ/PLAYER-relay) all ride
c.tcp_fd. - UDP mode: control + data on 7777 (not yet wired; legacy UDP path kept).
x4mp.cpp:g_legacy_net,g_listen_sock(host TCP listener),g_client_tcp_*(client).net_init_host: bind TCP listener on 7778 (new mode) instead of UDP 7777.net_init_client: create TCP socket + connect to host 7778 (new mode).net_poll: new-TCP case — host accepts per-client conns (NetClient created on ACCEPT) + recvs perc.tcp_fd; client recvs fromg_sock, routes OBJ/PLAYER ->x4mp_stream.dataevent, WELCOME/PING -> process_message.net_send_ctrl/net_send_ctrl_host/net_send_client: route control overc.tcp_fd/g_sock(new mode) or UDP 7777 (legacy). WELCOME/PING/SNAP use them.net_update: don't bail ong_sock<0in new mode; skip host->client TCP connect; client reconnects on drop.
x4mp_stream.cpp:on_stream_dataevent bridge ->parse_line;g_sub_datasubscription.- init: skip socket/recv when
X4MP_LEGACY_NET=0(x4mp owns the connection).
x4mp_launcher.sh: "Net mode" prompt (legacy / consolidated) + summary line.x4mp-release/README.md: transport section + config table (X4MP_TRANSPORT, X4MP_LEGACY_NET) + firewall note.
- Both extensions compile clean; deployed to local + host (md5-verified). Default is still LEGACY, so the current working setup is untouched.
- NOT YET TESTED in-game (needs both machines + a display; user at work).
- UDP consolidated mode (control+data on 7777) also implemented (host data via g_sock; client routes OBJ/PLAYER to the event; no separate data socket).
- New build md5: x4mp.so
dc3f58eceeb1dcb167d5889a57003c22, x4mp_stream.so15b7621c23489ccec070fc49bfaa8f38. - x4mp-release/ distribution package updated with the new .so files.
- Host: launcher -> role 1 -> Net mode: consolidated -> transport tcp.
- Client: launcher -> role 2 -> Net mode: consolidated -> transport tcp.
- Verify: host log "HOST listening on TCP port 7778 (consolidated control+data)"; client log "CLIENT connecting to :7778"; client ships reconcile (no flicker).
- If anything misbehaves: set
X4MP_LEGACY_NET=1(legacy) to fall back instantly.
- Client reconnect creates a 2nd NetClient on the host (old one pruned after the 30s host timeout). Acceptable for now.
- UDP consolidated mode (control+data on 7777) not yet wired; legacy UDP works.
Host sent HOST send error fd=35 errno=9 (EBADF) every ~10 s; client saw
recv=0, reconnected; host g_clients grew to 156 (prune never fired);
net_update degraded 1 ms -> 8 ms/frame.
Fixes in x4mp.cpp (all deployed + verified):
- Accept dedupe: on
accept(), close+erase stale entries with the same peer IP (reconnects used to leave the old entry forever). - Dead-entry cleanup (%300): consolidated-TCP entries with
tcp_fd < 0are unrecoverable and are erased. net_close_fd(fd, site): every socket close now logsCLOSE fd=N site=....net_dump_fds(why): /proc/self/fd table dumped on accept, on EBADF, and every %300 ticks. Prune block logs that it runs. Result: connection stable 28+ min (was ~10 s). The original silent fd closer was never positively identified, but all close sites are now traced, so any regression names its culprit immediately.
With consolidation, two client-side wiring gaps surfaced:
- FULL lines were dropped: x4mp's client line router only forwarded
OBJ/PLAYER to the x4mp_stream event bridge;
FULL 1went to process_message and vanished -> g_full_received stayed 0. (x4mp.cpp: FULL now routed to the bridge in both TCP and UDP branches.) - link_alive stayed false: g_last_recv_any was only refreshed in the legacy recv_loop; the event bridge never refreshed it. (x4mp_stream.cpp: on_stream_data now refreshes it.)
With FULL + link alive, missing-ship PRUNING activated — and it deleted the
PLAYER'S OWN SHIP: when the save's player is DOCKED,
GetPlayerControlledShipID() returns 0, so rebuild_local_index's exclusion
failed, the docked player ship entered the local index, the host never
streams it (host skips its own player from OBJ), and prune removed it after
10 s -> Game Over - Player has died (killmethod=removed) -> main menu ->
extension re-init -> save reload -> loop (32+ cycles observed).
Fix (x4mp_stream.cpp + x4mp.cpp): PlayerShipIDs collects the player ship
from ALL sources (GetPlayerControlledShipID, GetPlayerShipID,
GetPlayerOccupiedShipID, GetPlayerObjectID, its "ship" context); used both
at index-build time and re-checked live in the prune loop. Same exclusion
hardened in thin_client_cleanup_own_objects (X4MP_CLEANUP=1 path).
Game comm is Main(). pkill -x 'Main()' treats () as an empty regex
group and matches nothing; pkill -f "X4 -nologo" matches (and kills) its
own shell. Use escaped parens pkill -x 'Main\(\)' or kill by PID.
- Client loads save_009 (X4MP_SAVE MUST be passed to the client launch too — without it the client starts thin-client NEW-GAME Boron1, which has its own unstable campaign-cue behaviour).
- 1 reload cycle, 0 Game Over, 0 extra MD-cue errors, full=1 link=1, bindings=9/158 index, host clients=1 stable, host ~27 fps.
- New md5s: x4mp.so
57da1a3976e4c7a1f3337083f6f9d791, x4mp_stream.so20c5f6dfe9a8a13a9bc172bf2673f3b3.
- Client ghost ships never spawn on the host: faction
x4mp_client_Nis never registered as a real game faction (SpawnObjectAtPos2 fails). - Host streams only a subset of a sector's ships (GetAllFactionShips is capped at 2048/faction).
- x4mp_stream DBG summary/FLK lines only appear after 300 render ticks; short cycles never reach them (dbg_tick is static per extension load).
SpawnObjectAtPos2 requires an EXISTING faction; the old fake factions
(x4mp_host / x4mp_client_N) made every player-ship ghost fail. Fix: ghosts use
REAL factions picked deterministically from the sorted real-faction list
(excluding "player" AND the faction the player currently pilots — campaign
saves have the player aboard a foreign "alliance" ship). F_HOST = index 0,
F_CLIENT_N = index N. Faction is computed LAZILY at PLAYER/ghost time (JOINs
arrive before the host universe exists). Verified: "HOST spawned client ghost
... faction=antigone".
~9 enumeration sites used fixed 2048 stack buffers. New enumerate_faction_ships/stations helpers size heap buffers from GetNumAllFactionShips/Stations. Host now indexes all ~84k ships ("refreshed ALL ships: 84456").
ACT BUILD <seq> <macro> <pos> <rot> <sector_macro>: client scans
player-faction stations every ~10 s, baseline = the save's stations at ready
(only NEW builds reported), re-sends each cycle (host dedupes via
spawned[seq]). Host spawns the station ghost under the client's ghost faction
in the mapped sector, marks host-only, removes on disconnect. Functional test
needs a user-built station.
Three bugs combined: (a) ghost faction collision (host ghost under the piloted "alliance" faction looked like the client's own ship) — fixed by the pilot-faction skip. (b) g_bound_locals LEAK: zone-cleanup dropped a RemoteObj without releasing its binding -> local ship skipped by prune forever (frozen stale ships). (c) INDEX DUPLICATES: the same ship is returned by multiple factions' enumerations (includehidden aliases) -> index=158 was really ~10 unique ships; duplicates masked (b). Fixed: dedupe in rebuild_local_index + release bindings on zone-drop. Verified: "10 ships, 148 dupes dropped", bound_locals == bindings == 8, client view now matches host truth. Fleet reassignment (hide host's PF fleet on client) implemented but correctly skips campaign saves where PF ships are the player's own.
- "Extra launch options": numbered multi-select (1 = -showfps, 2 = -nocputhrottle); enter "1,2" for both; custom flags can be typed directly.
- "Net mode": numbered single-choice (1 = consolidated, 2 = legacy). Synced to all 4 copies (md5 36a35dfc20844daedbebfe748ec61250); tarball rebuilt.
x4mp.so 30d2d2cd3e5d974c98f71de9555a1401,
x4mp_stream.so 8a4dce4648a3bd2b444e07673c531a0f.
- Host autosave can bake ghost objects into the save (observed: sim satellite from an old session is now IN save_009). Mitigate by not long-running the host, or prune ghosts before save.
- Host-built stations are not yet replicated to clients (client->host works).
- Host fps ~25 with one client (high-sim cost, accepted).
Symptom: the host log flooded with
SpawnObjectAtPos2(): Failed to retrieve owner faction with name '<cluster_NN_sector001_macro>' — ~2.4M times/hour, in dense per-frame bursts,
across every sector the player visited. Client log: 0 errors. ghosts=0,
no x4mp_stream: [DBG] SPAWN lines, yet the host's x4mp_stream g_objs
ballooned to ~250 with garbage ids (~2^64).
Root cause: the consolidated-UDP receive loop in x4mp.cpp
(recvfrom(g_sock, …) ~line 2142) forwarded incoming data lines
(OBJ/PLAYER/FULL/STA/KILL/PLAYERDIED/CARGO/CAPTURE/TRADE) to
x4mp_stream.data without a role gate, so it ran on the host too.
The host fed the client's own PLAYER lines into the host's x4mp_stream.
The client's PLAYER format is cid-less (PLAYER x y z yaw pitch roll macro csector), but x4mp_stream's parser expects a leading cid
(PLAYER cid … macro faction sectormacro). The missing cid shifted every
field by one, so the client's sector macro landed in o.faction. Each such
object is is_player=true (never binds), so x4mp_stream called
SpawnObjectAtPos2(ship_macro, sector, pos, "<sector macro>") every frame; the
game rejected it (returns 0, o.spawned stays false) → infinite retry flood.
The client's moving x-position was mis-read as a fresh id, growing g_objs.
Impact on sync: none. The phantom spawn never succeeded, so nothing was created or corrupted. Real player-ship exchange (client sends position each tick → host moves into that sector → host streams it back) worked underneath.
Fix: gate the forward to client-only — if (g_net_client && !g_legacy_net && g_transport == "udp" && …). The consolidated-TCP path was already
client-gated (else if (g_net_client)); only the UDP path missed the check.
On the host, received PLAYER/ACT lines now go to process_message, which
spawns the client ghost with the correct cfaction.
Deployed md5 (this fix): x4mp.so efb4f15ded4a0b4272cc0ceca27a1634
(both machines + release). x4mp_stream.so unchanged
14ec256989ba92acd3a34434d72fc1da. Tarball rebuilt.