Branch: multiplatform-shim (f0650f0), web (JS) target.
Symptom
Temporary objects the server swaps in place render late on the web client, sometimes for tens of seconds. While the old model is still drawn the client sends the old object id on click, and the server rejects it. Seen with the evil tree D&D (GregHib/void#1256): the burst root model (11429) stayed after the server had already settled it to the choppable root (11428), and the young tree (11395) stayed after the server had grown it (11434). Server log during the session:
19:59:30.690 WARN [ObjectOptionHandler] Invalid object 11429 Tile(3093, 3311, 0)
19:59:34.890 WARN [ObjectOptionHandler] Invalid object 11428 Tile(3093, 3311, 0)
19:59:45.118 WARN [ObjectOptionHandler] Invalid object 11395 Tile(3092, 3312, 0)
20:00:16.359 WARN [ObjectOptionHandler] Invalid object 11395 Tile(3092, 3312, 0)
Every logged id is the object the tile held before the last swap. From the player's side the roots look unchoppable / never disappear. The desktop (JVM) client with a full local cache does not show this.
What was checked
The server side (GameObjects, ZoneBatchUpdates, tick order) sends ObjectRemoval + ObjectAddition in the same batch for every swap. On the client the whole path was compared against the Java 634 deob and is a line-for-line port: framing (Client.kt ~2730), batch loop (opcode 48, Client.kt ~3600), zone decoders (InputStream_Sub2.kt), spawn record BoundingBoxNode.method2800, per-tick apply WidgetTextConfig.method362, scene apply MapSceneIconDef.method1591 / WorldMapSceneSoftware.method1694, net/Socket.js.kt chunk reads, and the void Proxy.kt relay. No divergence.
Cause
WidgetTextConfig.method362 only applies a spawn record once SolidFillComponent.method195 → ObjectType.method478 → Js5Archive.method420 reports the new object's model group is in the cache; until then the record stays with delay 0 and the tile keeps showing the previous object. Removals (id = -1) skip that check, but a burst→settle or a growth stage is a removal plus an addition on the same tile in one batch, so the record ends as an addition and waits for the model.
On the JVM the cache is complete, so the wait is ~0. On the web the cache is streamed over JS5 through the WebSocket relay into MemFs/IndexedDB, so every model not seen before is a JS5 round trip, and those requests compete with everything else the fresh cache is streaming:
ArchiveResourceProvider.method2350(group, 65, 0) issues an urgent request only if fewer than 20 urgent requests are queued or in flight (MediaStreamClient.method1900); otherwise it returns null and the spawn record simply retries next tick. On a fresh web cache the 20 urgent slots are permanently occupied by scene models and map data.
MediaStreamClient.method1893 is the only place JS5 responses are read, it runs once per render frame (Client.method114 → method102), and it drains at most 100 × 512 bytes per call. On the JVM that is ~2.5 MB/s at 50 fps. The web build's frame rate is far lower with the software renderer, and Chrome throttles the setTimeout chain in PlatformLoop.js.kt to a few ticks per second whenever the tab is not visible, so JS5 throughput drops with it and the urgent slots free up slowly.
- Sending is also frame-coupled:
SocketStreamWorker.method1470 only hands bytes to ws.send when the writer coroutine next resumes.
So the model for the new object arrives late, and everything the server swaps on a tile is drawn as its previous state until then.
Suggested fix
Decouple JS5 servicing from the render frame on the web target, at the source rather than with client-side timeouts:
- Read budget: replace the fixed
for (i_1_ in 0..99) in MediaStreamClient.method1893 with a platform value (e.g. expect val js5ReadsPerFrame: Int next to executeWorkerTasksInline in PlatformLoop.kt; JVM 100, JS Int.MAX_VALUE). On JS the WebSocket only delivers between macrotasks, so available() is a fixed snapshot and draining it fully is bounded; on the JVM the cap still guards against the socket refilling during the loop.
- Pump on data, not on frames: in
jsMain service method1893 from the socket's onmessage (or a short setInterval) so responses are consumed and queued requests are sent even when frames are slow or the tab is throttled. method1893 also owns the 30 s idle reconnect, so it needs to keep running when the loop is throttled.
- Optional: the JS
SocketStreamWorker writer could ws.send directly from method1470 instead of via the coroutine, since there is no blocking to avoid on the web.
The 20-slot urgent cap is protocol behaviour and can stay; once throughput is not tied to the frame rate it clears quickly.
Repro
Fresh web cache (clear the IndexedDB store), log in, ::evil_tree 0, nurture it to full, watch a root burst: the burst model lingers well past the 2 ticks the server keeps it, clicks warn Invalid object 114xx. Same steps on the JVM client swap instantly. Config.debug = true logs Packet read: <opcode> in the console to confirm the batches (opcode 48) arrive on time.
Branch:
multiplatform-shim(f0650f0), web (JS) target.Symptom
Temporary objects the server swaps in place render late on the web client, sometimes for tens of seconds. While the old model is still drawn the client sends the old object id on click, and the server rejects it. Seen with the evil tree D&D (GregHib/void#1256): the burst root model (
11429) stayed after the server had already settled it to the choppable root (11428), and the young tree (11395) stayed after the server had grown it (11434). Server log during the session:Every logged id is the object the tile held before the last swap. From the player's side the roots look unchoppable / never disappear. The desktop (JVM) client with a full local cache does not show this.
What was checked
The server side (
GameObjects,ZoneBatchUpdates, tick order) sendsObjectRemoval+ObjectAdditionin the same batch for every swap. On the client the whole path was compared against the Java 634 deob and is a line-for-line port: framing (Client.kt~2730), batch loop (opcode 48,Client.kt~3600), zone decoders (InputStream_Sub2.kt), spawn recordBoundingBoxNode.method2800, per-tick applyWidgetTextConfig.method362, scene applyMapSceneIconDef.method1591/WorldMapSceneSoftware.method1694,net/Socket.js.ktchunk reads, and the voidProxy.ktrelay. No divergence.Cause
WidgetTextConfig.method362only applies a spawn record onceSolidFillComponent.method195→ObjectType.method478→Js5Archive.method420reports the new object's model group is in the cache; until then the record stays with delay 0 and the tile keeps showing the previous object. Removals (id = -1) skip that check, but a burst→settle or a growth stage is a removal plus an addition on the same tile in one batch, so the record ends as an addition and waits for the model.On the JVM the cache is complete, so the wait is ~0. On the web the cache is streamed over JS5 through the WebSocket relay into
MemFs/IndexedDB, so every model not seen before is a JS5 round trip, and those requests compete with everything else the fresh cache is streaming:ArchiveResourceProvider.method2350(group, 65, 0)issues an urgent request only if fewer than 20 urgent requests are queued or in flight (MediaStreamClient.method1900); otherwise it returnsnulland the spawn record simply retries next tick. On a fresh web cache the 20 urgent slots are permanently occupied by scene models and map data.MediaStreamClient.method1893is the only place JS5 responses are read, it runs once per render frame (Client.method114→method102), and it drains at most 100 × 512 bytes per call. On the JVM that is ~2.5 MB/s at 50 fps. The web build's frame rate is far lower with the software renderer, and Chrome throttles thesetTimeoutchain inPlatformLoop.js.ktto a few ticks per second whenever the tab is not visible, so JS5 throughput drops with it and the urgent slots free up slowly.SocketStreamWorker.method1470only hands bytes tows.sendwhen the writer coroutine next resumes.So the model for the new object arrives late, and everything the server swaps on a tile is drawn as its previous state until then.
Suggested fix
Decouple JS5 servicing from the render frame on the web target, at the source rather than with client-side timeouts:
for (i_1_ in 0..99)inMediaStreamClient.method1893with a platform value (e.g.expect val js5ReadsPerFrame: Intnext toexecuteWorkerTasksInlineinPlatformLoop.kt; JVM100, JSInt.MAX_VALUE). On JS the WebSocket only delivers between macrotasks, soavailable()is a fixed snapshot and draining it fully is bounded; on the JVM the cap still guards against the socket refilling during the loop.jsMainservicemethod1893from the socket'sonmessage(or a shortsetInterval) so responses are consumed and queued requests are sent even when frames are slow or the tab is throttled.method1893also owns the 30 s idle reconnect, so it needs to keep running when the loop is throttled.SocketStreamWorkerwriter couldws.senddirectly frommethod1470instead of via the coroutine, since there is no blocking to avoid on the web.The 20-slot urgent cap is protocol behaviour and can stay; once throughput is not tied to the frame rate it clears quickly.
Repro
Fresh web cache (clear the IndexedDB store), log in,
::evil_tree 0, nurture it to full, watch a root burst: the burst model lingers well past the 2 ticks the server keeps it, clicks warnInvalid object 114xx. Same steps on the JVM client swap instantly.Config.debug = truelogsPacket read: <opcode>in the console to confirm the batches (opcode 48) arrive on time.