Skip to content
You are reading the docs for Melange 0.8.0. Read the latest.

Wormsign

Wormsign is the match’s tick clock and state hash. A tick is one 20 ms step of the game’s simulation, 50 per second. At the end of each tick Wormsign hashes the logic RNG, the turn, the worms, the scheduled tasks, the projectiles and the teams (the engine hash), plus whatever mods feed in (the mods hash). Two machines that agree on a tick’s hashes agree on the game state at that tick.

A recording (Documents\Melange\replays\*.wsr) holds what is needed to play a local match again: the seeds, the random draws made in the menus before the match, every input with its time, the hash of every tick and a fingerprint of the match setup. A replay plays the match again with those inputs and compares each tick with the recording.

  1. Open the overlay panel Wormsign/Replay at the main menu and arm a recording.

The overlay’s Wormsign/Replay panel: the recording library, with land and tick counts, and an Arm button per entry 2. Start a Quick Game from the main menu. The replay needs the same route into the match as the recording: the match setup is rebuilt from the recorded menu draws, not written directly. 3. At the first tick the setup (level, land, theme, scheme and teams) is checked against the recording. If it differs, the replay stops with “setup differs” and names the first difference. It never reports that as a divergence. 4. While the replay plays, your own inputs are ignored and the recorded ones are sent at their recorded times. Pause, change the speed (0.25x to 8x) or run to a tick. Running to a tick that has already passed needs a restart: Restart arms the recording again, and once you quit the match you start the Quick Game again. 5. The panel counts the ticks compared and matched. At the first tick that differs it shows “Diverged at tick N” and which parts of the state differ (diff). The replay keeps playing so you can watch what happens next.

Arming is refused while you are in a lobby or online, during a match, for a recording of an online match, for a recording made with another game build, and for one made with different mod content (unless [Wormsign] ReplayAnyContent=1). A recording whose hash version differs from this build’s plays without being compared.

The wormsign.replay test command does the same from scripts: arm <file>, disarm, pause, resume, speed <x>, runto <tick>, restart [tick] and status.

The Oasis Replays panel’s library: three recordings with their duration, size and land, one flagged desync and one incomplete

The replay timeline for one .wsr file: one row per engine component, a divergence marker, and the selected tick’s hashes below

The divergence diff viewer opened on a desync bundle .zip: the report, the differing fields table, and diff.txt

Setting ([Wormsign]) Default
ReplayAnyContent 0 1 replays a recording made with different mod content
ReplayPassLocal 0 1 lets local-only inputs (the camera) through while a replay plays; by default every live input is ignored
HashCamera 0 1 adds the melange.camera contributor: the active logical camera in the mods hash of every tick (see The logical camera)

The container (chunks, compression, index, recovery of truncated files) is described in src/wormsign/format.h. These are the payloads of the record chunks, little-endian and packed:

Chunk Payload
SEED n × {u8 kind, u32 value, u32 caller, u32 t}: kind 0 is the logic RNG, 1 the second RNG; caller is the return address of the seed call
PDRW n × {u8 rng, u32 ret, u32 stateAfter, u32 bits}: the random draws made before the match, in order; bits is the result (an integer or float bits)
INPT n × {u8 type, u16 id, u32 a, u32 b, u32 time, u32 callT, u32 caller, u8 strLen, strLen bytes}: type 0-5 is message, int, two ints, float, two floats, string; a and b are the values (float bits for floats); time is when the input takes effect and callT the logic time of the call
TICK u32 firstTick, then records: 0 followed by {u64 engine, u64 mods, u64 c[6], u32 rngLogic, u32 rng2, u16 fpucw, u16 inputs} for one tick (the next tick number follows), or 1 followed by u32 n for n missing ticks
SETP JSON: v, level, landFile, landTheme, dataBank, timeOfDay, levelDetails, lastScheme, schemeName, scheme and init (16-digit hex hashes of the scheme and team setup), teams: [{name, worms}]

The HEAD chunk’s contributors (an array of {name, version}) decides whether a replay also compares the mods hash: only when the recording and the running game have the same set.

A recording is written while the match runs. HEAD, SEED and PDRW reach the disk when the match starts, SETP at its first tick, and the ticks, inputs and details every 500 ticks (10 s); the writer thread flushes every batch it writes. If the game crashes, the crash handler gives the writer what is still buffered and a NOTE with "reason":"crash", and waits up to 3 s for it to reach the disk (recording: ... on disk in Melange.log). Without that, a crash loses at most the last 10 s.

A file without INDX and the trailer is incomplete: the reader keeps every whole chunk up to the first damaged or missing one. The library, the replay panels and tools/wsr/wsr.py info show it as incomplete. Export last game’s logs includes it, redacted like any recording and marked "incomplete": true in manifest.json. A file too short to hold its HEAD chunk (an empty one, for example) has nothing that can be redacted, so the export lists it under absent with the reason (“empty: the game crashed before the recording was written”).

If reading another module’s state at the match start faults (the game state, the mod content id, the contributors, the RNG logs), the recording continues without that one value and the log names the read and the exception.

In an online match every player runs the same simulation from the same inputs. When one machine’s state drifts, the game notices only at the end of a turn, and then ends the match with “This session is no longer available”. Wormsign compares the hashes of every tick with the other Melange players in the lobby, so a desync is reported at the tick where it happened, usually within a second.

  • Who it talks to. Each Melange player with [Wormsign] Exchange=1 writes the lobby member key mlg.ws=1. Hashes go only to members whose mlg.ws is the same version. A player without Melange, or with Wormsign off, gets nothing: Melange never sends them a packet, and a match against them is byte-for-byte the same on the wire as without Melange.
  • How. Steam P2P channel 5, which the game neither uses nor reads. A batch of tick hashes goes out every half second, about 1.2 KB/s per player.
  • What it compares. The engine hash, always. The mods hash, when both players have the same mod contributors; otherwise the Wormsign/Peers panel says “mod contributors differ”. With HashCamera=1 the mods hash includes the logical camera (melange.camera), so it is compared only with players that have it on too.
  • What a desync does. Nothing to the match: Wormsign never pauses or ends it, and the game’s own turn-end check keeps working as before. Wormsign shows a message (“Desync at tick 5000 (worms) with player, bundle saved”), writes the log, and saves a desync bundle. [Wormsign] OnDesync=bundle-only keeps the message off the screen.

The overlay panel Wormsign/Peers lists every lobby member: whether hashes are exchanged (“no exchange” for a player without it, “version mismatch” for another protocol), the last tick both sides compared, how many ticks the other player is behind, and, after a desync, how long the two have stayed apart.

Documents\Melange\replays\desync-<date>-p<process>-m<match>-t<tick>.zip holds:

File Content
report.json The tick, both engine and mods hashes, which engine parts differ, the first differing mod contributor, every lobby member’s exchange state, Melange version and content hash, and the game’s own turn-end checks during the match
diff.txt One line per field that differs at that tick, ours first: worms[3].energy 100 -> 1
detail-local.json, detail-peer.json The state at that tick on both machines, as far as it is known
match.wsr The match recording, when one is kept
logs/Melange.log, logs/session.jsonl The last two minutes of both logs
mods.json, sysinfo.txt The enabled mods, and the system information of Save logs as…

Bundles follow the redaction of Save logs as…: SteamIDs and IP addresses are replaced with a hash that is different in every bundle, and the Windows user and computer names are replaced. Export last game’s logs includes every bundle and recording of that game (matched by the pid in the file name); Save logs as… includes the newest bundle and recording.

When the game’s own turn-end check later fails, the log and the panel say how far behind it was and name every check that failed, not only the first: “Wormsign flagged tick 5000; the engine’s turn-end check failed at tick 7488 (reasons 7,8,9,11,13: camera’s active view matrix differs; camera’s logical position differs; camera’s logical target position differs; worm data mismatch; local and remote worm data differ)”, or “(reason 5: Random’s dont match)” for one. In session.jsonl the engine validation and engine correlation records keep reasons as the bitmask (bit n-1 for reason n) and add reasonList (“7,8,9,11,13”); engine validation’s reason and engine correlation’s reasonText hold every failed check’s text joined by “; “, and engine correlation’s numeric reason stays the first one. In report.json and the ENGV chunk each check’s reasons lists every number and reason every text.

The game’s reasons are: 1 source of validation differs, 2 no active worm, 3 worm indexes differ, 4 task list, 5 random numbers, 6 active camera name, 7 the active logical camera’s view matrix, 8 its position, 9 its target, 10 its up vector, 11 worm data, 12 weapon inventory, 13 local and remote worm data. Reasons 7-10 hash the active logical camera (GameStateValidationMsg 0x68a027/0x68a335 through CameraManagerService::GetLogicalViewMatrix 0x562730), the camera the simulation steps every tick, not the one on the screen.

The engine hash does not include the camera. With [Wormsign] HashCamera=1, Wormsign adds the contributor melange.camera (version 1), which feeds the mods hash of every tick with the active logical camera’s raw position, target and up vector (camera +0x04, +0x10, +0x1c; the camera is *(0x95c370)’s camera vector at +0x2a0 indexed by m_uLogicalCamera at +0x28c) and a flag for whether it could be read. Those are the values reasons 7-10 compare, as stored bits rather than the engine’s sums of them, so a camera that drifts mid-turn is reported at its first differing tick. The camera index, the number of cameras and the view mode (+0x2c) are only in the detail record: the engine compares the camera by name (reason 6), and they need not be the same on every machine. The reads are fault guarded and only read memory.

Every detail record has the camera (camera in wormsign.detail, detail-local.json and detail-peer.json: present, index, count, view, pos, target, up and the contributor hash), with or without HashCamera; wormsign.detail also says whether the record’s camera matches the tick’s melange.camera hash.

Compatibility with players who do not have it (an older Melange, or HashCamera=0):

  • It is a contributor, not an engine component. An engine component would change the engine hash version, and a peer with another version gets no exchange at all (“version mismatch”).
  • The mods hash is compared only between players whose contributor lists match (contributorListHash in HELLO). A player without melange.camera has another list, so with them only the engine hash is compared: no false desync, and a real engine desync is still found. The Wormsign/Peers panel says “mod contributors differ” for them. That also means the other mod contributors are not compared with that player.
  • Recordings list their contributors, so a replay compares the mods hash only when the recording has the same set (a recording made without melange.camera plays with the engine hash only).
  • An older Melange’s detail record has no camera, so a bundle from a desync with it lists camera.* as “(none)” on its side in diff.txt.
  • It is off by default until two players have run whole matches with it and agreed: the game checks the logical camera only at turn ends and time syncs, so that it also agrees between those checks is not yet verified. Turn it on on every machine of a test match. A camera desync names melange.camera as the differing contributor, and diff.txt names the fields (camera.target.x 512.25 -> 512.5).

The girder kit’s aim (GirderKitLogicEntity 0x55a680, its +0xdc..+0xf0 vectors) is computed from the same camera’s target minus position, so it is covered through the camera; the girder kit entity itself is not hashed.

A sim mod must do the same thing on every machine. A mod that reads something only one machine has (a local setting, the camera, the time) desyncs the match. dist\Mods\desync-probe (disabled by default) is a small content mod for trying the detector: on the local event sim.test.desync it changes a global of its own (kind 0) or draws an extra number from its random stream (kind 2). Enable it on both machines of a LocalNet or Steam match, raise the event on one of them, and the detector reports the tick with the mod’s contributor named.

melange/wormsign.h has the tick clock, the hashes and the observers:

  • OnDivergence(fn, user): called on the main thread with a Divergence for every desync with another player (Source::Peer) or a replay (Source::Replay): the tick, both hashes, the engine parts that differ (compMask), the first differing contributor and the other player’s SteamID. bundle is empty then; the bundle is written a few seconds later.
  • AddContributor(name, fn, user) feeds a module’s own state into the mods hash.
  • OnSession(fn, user) and OnTickEnd(fn, user, order) run fault-guarded: an observer that faults is skipped for that call, and the first fault is logged with the list, the handle, the observer’s name (Melange’s own observers have one; another module’s is named by the module its function is in) and the exception: [wormsign] session observer 1 (wormsign recorder, 70a1c2d0 melange.asi+0x1c2d0) faulted and was skipped: c0000005 at ..., read of address 00000000. After that it is logged at 10, 100, 1000 … faults.

Client Lua has wum.wormsign.onDivergence(fn) and the event wormsign.divergence (lua-api.md).

The automation verb wormsign.peers logs the exchange with each lobby member and the packet counters; wormsign.stats logs the tick clock and the hash cost.

Little-endian. Every packet is at most 1100 bytes and starts with an 8-byte header: the magic WSX1, u8 kind, u8 protocol (1), u16 total length. A packet with a wrong magic, protocol or length is dropped. matchKey is an FNV-1a-64 of the lobby owner’s SteamID and the lobby id.

Kind Sent Content after the header
1 HELLO unreliable; every second until the other side has ours, then every 10 s u32 engineHashVersion, u64 matchKey, u32 firstTick, u64 contributorListHash, u8 flags (1: we have yours, 2: names cut), str8 melangeVersion, str8 contentHash16, u8 n, n × {str8 name, u32 version}
2 HASHES unreliable, every 25 ticks u64 matchKey, u32 firstTick, u8 n (≤ 50), u64 presentMask, n × {u64 engine, u64 mods} for ticks firstTick..firstTick+n-1; each batch repeats the previous one’s last 15 ticks
3 COMPS? reliable, on a desync u64 matchKey, u32 tick
4 COMPS reliable, the answer u64 matchKey, u32 tick, u8 have, u64 engine, u64 mods, u64 components[6], u8 n, n × u64 per contributor, in HELLO order
5 DETAIL? reliable, on a desync u64 matchKey, u32 tick
6 DETAIL reliable, the answer u64 matchKey, u32 tick, u16 index, u16 count, u32 total, u16 len, bytes: the tick’s detail record as JSON, at most 64 KB in 1 KB chunks
7 FLAG reliable, on a desync u64 matchKey, u32 tick, u64 engine, u64 mods, u64 components[6]: the sender’s view of the first differing tick

str8 is a length byte and that many bytes. The engine components are, in order, time and logic RNG, turn, worms, tasks, projectiles and teams. The contributor list hash is FNV-1a-64 over each name, a zero byte and its u32 version. Detail records are answered at most 4 times a minute per player, component requests 8 times, and only for the current match; nothing received is ever run or used as a path.