Skip to content

Web client draws replaced objects until their models stream over JS5 (frame-coupled JS5 pump) #23

Description

@HarleyGilpin

Branch: multiplatform-shim (f0650f0), web (JS) target.

Image

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:

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions