TheBiky88

change report

Feb 25th, 2026 (edited)
163
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 21.60 KB | None | 0 0
  1. ================================================================================
  2. MOON-ENEAS MULTIPLAYER DESYNC FIX REPORT
  3. Files changed: camera.lua, events.lua, talk.lua, message.lua
  4. Additional fixes: debris mining crash + exploit, message.lua math.random
  5. ================================================================================
  6.  
  7. Hey, I looked through your mod's code and found what's causing the
  8. desyncs, and also spotted the crash + exploit issue from the other
  9. discussion thread. Nothing about gameplay was touched beyond what needed changing.
  10.  
  11. Here's a breakdown of what I found and changed, and why the original code
  12. was breaking things.
  13.  
  14. TLDR:
  15. - Don't use math.random(), use storage table to store predetermined rng values
  16. seeded by game.create_random_generator()
  17. - Don't call script.on_nth_tick() during runtime, it's meant to be only called
  18. at top level of any script or called in on_init, on_load and on_configuration_changed,
  19. which are called only when the game is loaded, never during runtime.
  20.  
  21. - Changed how script.on_nth_tick is called in camera.lua, using storage to
  22. determine when to clean up.
  23. - Added predetermined random generators for various situations inside the storage
  24. table, changed math.random calls to call those random generators instead.
  25. - Fixed the full inventory crash and the infinite loot exploit by switching from
  26. on_pre_player_mined_item to on_player_mined_entity.
  27.  
  28.  
  29. ================================================================================
  30. FIRST, SOME CONTEXT ON HOW FACTORIO MULTIPLAYER ACTUALLY WORKS
  31. ================================================================================
  32.  
  33. This is worth understanding before diving into the fixes, because both issues
  34. come from the same underlying misunderstanding of how Factorio handles joining
  35. players.
  36.  
  37. Factorio multiplayer is NOT like most games where the server streams world state
  38. to clients. Instead, every client runs the full simulation independently, tick
  39. by tick. The server only sends player inputs (clicks, keypresses, that kind of
  40. thing.) Because of this, every client must produce bit-for-bit identical game
  41. state on every tick.
  42.  
  43. When someone joins a running game, Factorio sends them two things:
  44. 1. The current save file (which includes the `storage` table and all entity
  45. state)
  46. 2. A list of which event handlers the mod has registered
  47.  
  48. The joining client loads the save, re-registers all its handlers by running the
  49. mod scripts fresh, and then both the server and the new client run forward from
  50. that point in lockstep.
  51.  
  52. This means two things have to be true for a mod to be multiplayer-safe:
  53.  
  54. RULE 1 - Event handler registrations must be identical on all clients.
  55. Any call to script.on_event(), script.on_nth_tick(), etc. must happen at load
  56. time, meaning at the top level of your scripts, or inside on_init / on_load /
  57. on_configuration_changed. A joining client re-runs those registrations from
  58. scratch. If the server registered a handler mid-game (inside a function that
  59. ran during gameplay), the joining client will never register that handler, and
  60. Factorio blocks the join with exactly the error you've been seeing: "mod event
  61. handlers are not identical."
  62.  
  63. RULE 2 - Random numbers used in game logic must be deterministic.
  64. Lua's math.random() is seeded by the Lua VM at startup, usually from the
  65. system clock. That seed is different on every machine and is not saved. So
  66. every client that starts up (including someone joining mid-game) begins its
  67. math.random() sequence from a different point. Any call to math.random() in
  68. a game event handler will produce different results on different clients,
  69. which means they'll create different entities, place things at different
  70. positions, etc. The game states diverge silently, and eventually you get a
  71. desync crash.
  72.  
  73.  
  74. ================================================================================
  75. FILE 1: camera.lua
  76. Problem: script.on_nth_tick() being called inside a runtime function
  77. ================================================================================
  78.  
  79. --- WHAT CHANGED ---
  80.  
  81. Inside ShowStaticTransmission(), there was this block at the end:
  82.  
  83. local cleanup_tick = game.tick + ((duration or 8) + 1) * 60
  84. script.on_nth_tick(61, function()
  85. if game.tick >= cleanup_tick then
  86. if static_entity and static_entity.valid then
  87. static_entity.destroy()
  88. end
  89. end
  90. end)
  91.  
  92. That's been replaced with:
  93.  
  94. storage.static_transmission_cleanup = {
  95. cleanup_tick = game.tick + ((duration or 8) + 1) * 60,
  96. position = STATIC_POSITION,
  97. }
  98.  
  99. And a new permanent handler has been added at the top level of camera.lua
  100. (outside any function, so it runs once when the mod loads):
  101.  
  102. script.on_nth_tick(61, function()
  103. if not storage.static_transmission_cleanup then return end
  104. local pending = storage.static_transmission_cleanup
  105. if game.tick < pending.cleanup_tick then return end
  106.  
  107. local nauvis = game.surfaces["nauvis"]
  108. if nauvis and nauvis.valid then
  109. local dummies = nauvis.find_entities_filtered{
  110. position = pending.position,
  111. radius = 5,
  112. name = "unit-05-dummy"
  113. }
  114. for _, entity in pairs(dummies) do
  115. entity.destroy()
  116. end
  117. end
  118.  
  119. storage.static_transmission_cleanup = nil
  120. end)
  121.  
  122.  
  123. --- WHY THE ORIGINAL CODE WAS BREAKING THINGS ---
  124.  
  125. ShowStaticTransmission() is called by on_research_finished() in talk.lua every
  126. time a tech gets researched. That happens mid-game, at runtime, not at load
  127. time.
  128.  
  129. Every time it ran, it called script.on_nth_tick(61, ...) with a new anonymous
  130. function. In Factorio, calling script.on_nth_tick() at runtime doesn't add an
  131. extra handler, it replaces whatever was registered for that interval with the
  132. new one. So after each tech research, the handler registered for interval 61
  133. changed to a new closure.
  134.  
  135. Now imagine someone tries to join the server after a tech has been researched.
  136. They load the mod from scratch. They've never had a tech fire during their
  137. session, so they've never called script.on_nth_tick(61, ...) mid-game. Their
  138. handler for interval 61 is either empty or is whatever got registered at load
  139. time. The server has a different, mid-game handler still active. Factorio
  140. compares the two handler tables, finds they don't match, and blocks the join.
  141.  
  142. This is also why you mentioned the bug feels timing-dependent and gets worse
  143. with more players. More techs researched means more chances for the handler to
  144. be in a mid-game state when someone tries to join, and more players means techs
  145. get researched faster.
  146.  
  147. The fix is straightforward: instead of registering a new handler each time a
  148. transmission fires, we just write the cleanup information into `storage`. Since
  149. `storage` is part of the save file, a joining player loads it automatically and
  150. gets the correct cleanup tick. The one permanent handler registered at load time
  151. (which every client always has) then picks it up on the next tick it fires.
  152.  
  153. One extra note: the original code stored `static_entity` directly in the closure
  154. as a LuaEntity reference. That's fine for a closure, but LuaEntity references
  155. can't go into `storage` either, Factorio will throw an error on save if you try. So
  156. instead, we store the entity's position as a plain coordinate table and find it
  157. again at cleanup time using find_entities_filtered. Since the dummy always spawns
  158. at {100000, 100000}, this works reliably.
  159.  
  160.  
  161. ================================================================================
  162. FILE 2: events.lua
  163. Problem 1: math.random() used throughout all runtime event handlers
  164. Problem 2: Crash on full inventory + infinite loot exploit
  165. ================================================================================
  166.  
  167. --- WHAT CHANGED ---
  168.  
  169. Five deterministic random generators are now created in init_debris() and stored
  170. in storage:
  171.  
  172. storage.rng = storage.rng or {}
  173. storage.rng.debris = storage.rng.debris or game.create_random_generator()
  174. storage.rng.pollution = storage.rng.pollution or game.create_random_generator()
  175. storage.rng.loot = storage.rng.loot or game.create_random_generator()
  176. storage.rng.talk = storage.rng.talk or game.create_random_generator()
  177. storage.rng.spawn = storage.rng.spawn or game.create_random_generator()
  178.  
  179. Every math.random() call in a runtime function has been replaced with the
  180. appropriate generator. Full list of replacements:
  181.  
  182. FUNCTION: find_valid_spawn_location()
  183. math.random(-spawn_radius, spawn_radius) -> storage.rng.debris(min, max)
  184. (x2, for x and y coordinates)
  185.  
  186. FUNCTION: select_random_debris()
  187. math.random() * total_weight -> storage.rng.debris() * total_weight
  188.  
  189. FUNCTION: scatter_fragments()
  190. math.random() * 2 * math.pi -> storage.rng.debris() * 2 * math.pi
  191. math.random() * scatter_radius -> storage.rng.debris() * scatter_radius
  192.  
  193. FUNCTION: execute_debris_crash()
  194. math.random(i) [surface shuffle] -> storage.rng.debris(i)
  195. math.random(min_fragments, max_fragments) -> storage.rng.debris(min, max)
  196.  
  197. FUNCTION: check_timed_events()
  198. math.random(min_interval, max_interval) -> storage.rng.debris(min, max)
  199. math.random(5*60*60, 15*60*60) -> storage.rng.debris(min, max)
  200. (these two schedule the next debris crash after a successful or failed attempt)
  201.  
  202. FUNCTION: execute_gas_venting()
  203. math.random(vent_min, vent_max) -> storage.rng.pollution(min, max)
  204.  
  205. FUNCTION: create_scaled_rewards()
  206. math.random(amounts.min, amounts.max) -> storage.rng.pollution(min, max)
  207.  
  208. FUNCTION: find_supply_drop_position()
  209. math.random() * 2 * math.pi -> storage.rng.debris() * 2 * math.pi
  210. math.random() * SUPPLY_DROP_SPAWN_RADIUS -> storage.rng.debris() * radius
  211. (note: even though the comment says this function is "old / not used for
  212. Eneas", it is still actively called from create_supply_drop_for_player()
  213. which gets triggered on tech research, so it wasn't dead code)
  214.  
  215. FUNCTION: on_debris_mined()
  216. math.random() -> storage.rng.loot()
  217. math.random(#bonus_loot_table) -> storage.rng.loot(N)
  218. math.random(min_count, max_count) -> storage.rng.loot(min, max)
  219.  
  220.  
  221. --- WHY THE ORIGINAL CODE WAS BREAKING THINGS ---
  222.  
  223. As explained above, math.random() is seeded locally on each machine at startup.
  224. Two clients running the same game will have completely different math.random()
  225. sequences from the very first call.
  226.  
  227. The practical effect: when on_debris_mined() fires and calls math.random() to
  228. decide if a bonus loot item drops, the server might roll 0.03 (yes, drop a
  229. productivity module) while a joining client rolls 0.74 (no drop). The server
  230. creates the item entity. The client doesn't. Their entity counts are now
  231. different. That's a desync.
  232.  
  233. The same thing happens in scatter_fragments() where math.random() picks the x/y
  234. coordinates for each debris piece. Different coordinates on server vs. client
  235. means different entity positions. Different positions means different game state.
  236.  
  237. What makes this really tricky to diagnose is that math.random() desyncs are
  238. silent and cumulative. Each divergent call pushes the clients a little further
  239. apart. By the time Factorio actually detects and reports the desync, you're often
  240. many ticks past the original cause, which makes it nearly impossible to find
  241. without specifically auditing every math.random() call.
  242.  
  243. The fix is game.create_random_generator(). This is Factorio's own RNG, seeded
  244. from the map seed which is identical on all clients. Its internal state gets
  245. stored in `storage`, so it's included in the save file. When someone joins,
  246. they load the generator in exactly the state the server has it, and from that
  247. point all clients produce the same sequence.
  248.  
  249. The `or` guards on each generator (e.g. `storage.rng.debris = storage.rng.debris
  250. or ...`) are important. init_debris() gets called again by on_configuration_
  251. changed when the mod is updated. Without those guards, updating the mod in an
  252. existing game would reset all the generators and introduce new divergence.
  253.  
  254. I split it into five separate generators (debris, pollution, loot, talk, spawn)
  255. rather than one shared one. If they all shared a single generator, then the
  256. debris system firing a few extra times (e.g. because players cleared debris
  257. faster than usual) would shift the sequence for pollution and loot rolls too.
  258. Keeping them separate means each system's randomness is independent and
  259. predictable.
  260.  
  261.  
  262.  
  263. ================================================================================
  264. FILE 2: events.lua (continued)
  265. Problem 2: Crash on full inventory + infinite loot exploit
  266. ================================================================================
  267.  
  268. --- WHAT CHANGED ---
  269.  
  270. Two things in the event registration block at the bottom of events.lua.
  271.  
  272. First, the spill_item_stack call inside on_debris_mined() was updated to match
  273. the Factorio 2.0 API. The old call passed 5 separate arguments, which is the
  274. Factorio 1.x syntax. Factorio 2.0 changed it to take a single table:
  275.  
  276. BEFORE:
  277. player.surface.spill_item_stack(
  278. player.position,
  279. {name = item_name, count = count - inserted},
  280. true,
  281. player.force,
  282. false
  283. )
  284.  
  285. AFTER:
  286. player.surface.spill_item_stack{
  287. position = player.position,
  288. stack = {name = item_name, count = count - inserted}
  289. }
  290.  
  291. Second, the event the handler listens to was changed:
  292.  
  293. BEFORE:
  294. script.on_event(defines.events.on_pre_player_mined_item, on_debris_mined)
  295.  
  296. AFTER:
  297. script.on_event(defines.events.on_player_mined_entity, on_debris_mined)
  298.  
  299.  
  300. --- WHY THE ORIGINAL CODE WAS BREAKING THINGS ---
  301.  
  302. The crash is straightforward. spill_item_stack changed its signature in Factorio
  303. 2.0 and the old multi-argument call just doesn't work anymore. That's exactly
  304. what the error in the bug report says: "Expected 1 argument but 5 were given."
  305.  
  306. The exploit is more subtle and comes from which event was being used.
  307. on_pre_player_mined_item fires the moment the player starts the mining animation,
  308. before the game knows whether mining will actually succeed. If the player's
  309. inventory is full, Factorio cancels the mining and the debris stays in the world.
  310. But the event already fired, so the loot roll already happened and any bonus
  311. items were already handed out.
  312.  
  313. That means a player can stand next to a debris piece with a full inventory and
  314. spam-click it forever. The debris never gets consumed, but every click has a
  315. chance to roll bonus loot and drop tier 3 modules on the ground. Infinite free
  316. modules at no cost.
  317.  
  318. Switching to on_player_mined_entity closes this completely. That event only fires
  319. after mining has successfully finished and the entity has been removed from the
  320. world. If the inventory was full and mining was cancelled, the event never fires,
  321. the loot roll never happens.
  322.  
  323. The robot handler was already using on_robot_mined_entity (the post-mining
  324. equivalent for robots), so that one didn't need changing.
  325.  
  326.  
  327. ================================================================================
  328. FILE 3: talk.lua
  329. Problem: math.random() used to pick unit-05 dialogue lines
  330. ================================================================================
  331.  
  332. --- WHAT CHANGED ---
  333.  
  334. One line in trigger_message():
  335.  
  336. BEFORE:
  337. local msg = unit05_messages[math.random(#unit05_messages)]
  338.  
  339. AFTER:
  340. local msg = unit05_messages[storage.rng.talk(#unit05_messages)]
  341.  
  342. The storage.rng.talk generator is created in init_debris() in events.lua,
  343. it's the fourth one in the init block shown above.
  344.  
  345.  
  346. --- WHY THE ORIGINAL CODE WAS BREAKING THINGS ---
  347.  
  348. trigger_message() runs inside a script.on_nth_tick(3600, ...) handler, once
  349. every in-game hour, for any player standing within 10 tiles of a unit-05
  350. entity. That handler runs in the synchronized game simulation on every client.
  351.  
  352. The tricky part here is that rendering.draw_text is called with
  353. `players = {player}`, so the text bubble only appears for that specific player.
  354. That part is fine. But math.random() gets called before that, and it runs on
  355. every client regardless. So even though you never see any visual difference,
  356. every client is independently advancing its math.random() sequence at different
  357. rates depending on how many players are near unit-05 on each client's version of
  358. the game.
  359.  
  360. This shifts the math.random() sequence differently on every client, and then
  361. the next time something like debris spawn fires and calls math.random() for
  362. coordinates, everyone gets different values. It's one of those bugs that is
  363. totally invisible in single player and very hard to connect to the actual
  364. desync crash in multiplayer.
  365.  
  366. Same fix as events.lua, use storage.rng.talk which is synchronized and
  367. deterministic. I kept it as a separate generator from the others so that how
  368. often players chat with unit-05 doesn't interfere with debris or loot rolls.
  369.  
  370.  
  371. ================================================================================
  372. FILE 4: message.lua
  373. Problem: math.random() in spidertron spawn and servofish capsule handler
  374. ================================================================================
  375.  
  376. --- WHAT CHANGED ---
  377.  
  378. Two math.random() calls replaced in message.lua.
  379.  
  380. First, find_water_spawn_position() which picks a random location on Eneas water
  381. to spawn the unit-07 spidertron when lurker-tech is researched:
  382.  
  383. BEFORE:
  384. local angle = math.random() * 2 * math.pi
  385. local distance = math.random() * radius
  386.  
  387. AFTER:
  388. local angle = storage.rng.spawn() * 2 * math.pi
  389. local distance = storage.rng.spawn() * radius
  390.  
  391. Second, the servofish capsule handler which picks a random unit-05 voiceline
  392. when a player uses a servofish item:
  393.  
  394. BEFORE:
  395. local message = voicelines.servofish[math.random(#voicelines.servofish)]
  396.  
  397. AFTER:
  398. local message = voicelines.servofish[storage.rng.talk(#voicelines.servofish)]
  399.  
  400. The storage.rng.spawn generator needs to be added to init_debris() in events.lua
  401. alongside the others. storage.rng.talk is already there from the talk.lua fix.
  402.  
  403.  
  404. --- WHY THE ORIGINAL CODE WAS BREAKING THINGS ---
  405.  
  406. find_water_spawn_position() runs inside spawn_spidertron_for_force(), which is
  407. called from on_research_finished() when the lurker-tech is researched. It uses
  408. math.random() to pick coordinates for where the spidertron spawns. Since this
  409. creates an entity in the world, different clients landing on different coordinates
  410. means different entity positions, which is a desync.
  411.  
  412. The servofish handler is the same situation as the unit-05 dialogue in talk.lua.
  413. The voiceline only appears to the player who used the capsule, but math.random()
  414. still runs in the simulation on every client, silently diverging their RNG
  415. sequences. Same fix, same reasoning, so it reuses storage.rng.talk.
  416.  
  417.  
  418. ================================================================================
  419. SUMMARY TABLE
  420. ================================================================================
  421.  
  422. File | Location | Old code | New code
  423.  
  424. camera.lua | ShowStaticTransmission() | script.on_nth_tick() | storage.static_transmission_cleanup = {...}
  425. | | called at runtime |
  426. | (new, top-level) | (missing) | script.on_nth_tick(61, ...) at load time
  427.  
  428. events.lua | init_debris() | (missing) | storage.rng.* = game.create_random_generator()
  429. | find_valid_spawn_location() | math.random(min, max) | storage.rng.debris(min, max) [x2]
  430. | select_random_debris() | math.random() | storage.rng.debris()
  431. | scatter_fragments() | math.random() | storage.rng.debris() [x2]
  432. | execute_debris_crash() | math.random(i) | storage.rng.debris(i)
  433. | | math.random(min, max) | storage.rng.debris(min, max)
  434. | check_timed_events() | math.random(min, max) | storage.rng.debris(min, max) [x2]
  435. | execute_gas_venting() | math.random(min, max) | storage.rng.pollution(min, max)
  436. | create_scaled_rewards() | math.random(min, max) | storage.rng.pollution(min, max)
  437. | find_supply_drop_position() | math.random() | storage.rng.debris() [x2]
  438. | on_debris_mined() | math.random() | storage.rng.loot()
  439. | | math.random(N) | storage.rng.loot(N)
  440. | | math.random(min, max) | storage.rng.loot(min, max)
  441.  
  442. talk.lua | trigger_message() | math.random(N) | storage.rng.talk(N)
  443.  
  444. events.lua | on_debris_mined() | spill_item_stack( | spill_item_stack{
  445. | | 5 args, old API) | position=..., stack=... }
  446. | event registration | on_pre_player_ | on_player_mined_entity
  447. | | mined_item |
  448.  
  449. message.lua | find_water_spawn_position() | math.random() | storage.rng.spawn() [x2]
  450. | on_player_used_capsule | math.random(N) | storage.rng.talk(N)
Advertisement
Add Comment
Please, Sign In to add comment