• Tracker

    Spaces
    Browse
    Statistics
  • /
  • /Stable Feedback
  • /DZG-839
Back
19

CanvasWidget::DrawLine has a hidden global buffer

UIGraphicsModded
19

State

Confirmed internally
Issue key
DZG-839
Access
Public
Space
DayZ
Project
Stable Feedback
Creator
6wingseraph
Created
Sep 19, 2026
Views
273
Platform
PC Steam
What happened?

There is buffer of approx ~10922 draw calls of ANY active DrawLine's on screen.
This limitation is hidden, not explicit by minimum
By maximum, move buffer from global, to local by CanvasWidget, so every canvas has it own buffer.

This hidden buffer overflow is leading to many undefined behavior.
Glitching shadows
Glitching UI
Many more

Reproduction steps

Create simple for loop for 12000 iteration, with drawing any stroke to the canvas widget

Exact build version (optional)
1.29
Where did it happen?
Local / Offline
Is it modded?
Yes
Expected result

Move buffer from global, to local by CanvasWidget, so every canvas has it own explicit buffer.

Section
Mod list

testmod

Load order / launch params
—
Reactions

Activity

    about 2 months ago
  • 6wingseraph
    created issueabout 2 months ago
  • about 2 months ago
  • gamemaster4commented
    about 2 months ago
    gamemaster4commented
    about 2 months ago

    + pls, Fix this asap

  • kramtsovcommented
    about 2 months ago
    kramtsovcommented
    about 2 months ago

    Yeah, that would be nice

  • about 2 months ago
  • l909
    changed state to
    ASSIGNED
    about 2 months ago
  • about 1 month ago
  • l909
    Moderator
    commented
    about 1 month ago
    l909
    Moderator
    commented
    about 1 month ago

    Hello.
    We have tested this but we could not reproduce the issue.
    Please provide original minimal repro mod with source, exact client build, GPU and graphics settings. We need the DrawLine parameters, canvas count/sizes, and whether calls accumulate or are cleared/redrawn each frame. A short video showing the first failing line count, the HUD/shadow corruption and what happens after Clear() would also help.

  • l909
    changed state to
    NEED MORE INFO
    about 1 month ago
  • 6wingseraphreplied to a comment(edited)
    about 1 month ago
    6wingseraphreplied to a comment(edited)
    about 1 month ago

    Hello, thanks for the response.

    Video: minimal repro mod in action - https://www.youtube.com/watch?v=TsGt41L9xis

    Repro: minimal repro mod - https://drive.google.com/drive/u/3/folders/1dArY1a3CxRk1CNl6o2kSBV7RkuU5zX77

    • Minimal mod with source attached, vanilla API only, tested on the DIAG build.

    • One full-screen CanvasWidget from a plain layout: ignorepointer 1, size 1 1, halign left, valign top, hexactpos 1, vexactpos 1, hexactsize 0, vexactsize 0.

    • Every frame: Clear(), then N × DrawLine. Each line is horizontal, 4 px long, width 4, opaque red (a 4×4 square), on a 200-column grid.

    • Up/Down arrows change N by 100. Backspace sets N to 0.

    • Result: from 0 to 11,900 lines there is no corruption. At about 12,000 lines corruption starts. The exact number depends on what other UI is on screen.

    Requested info

    • Client build: 1.29.0.163709, Steam Stable (build ID 24689949)

    • GPU: NVIDIA GeForce RTX 4090 (Gigabyte WINDFORCE)

    • Graphics settings: DayZ.cfg and graphics.DayZProfile are included with the mod source

    Root cause
    To help you out with solution, here's details on why its happening:

    CanvasWidget lines are drawn through the shared immediate-mode dynamic vertex buffer "VCacheVB", 2 MiB per buffer. When a frame needs more than that, the ring allocator wraps to offset 0. Because of a bookkeeping error, it keeps mapping with D3D11_MAP_WRITE_NO_OVERWRITE over the start of the same frame's data, which the GPU hasn't consumed yet. As a result, the vertices drawn first in the frame get overwritten.

    How a line reaches the GPU:

    1. CanvasWidget.DrawLine (RVA 0x40AC20 → 0x3FA9A0) appends two points {x, y, width, color} (16 B each) to a per-widget array at +0xF0. There is no limit at this stage. Clear() only zeroes the count at +0xFC. Lines stay until Clear() and are re-submitted every frame.

    2. On draw, each line becomes a non-indexed quad: 6 vertices × 28 B = 168 B. Vertices are staged 2,400 at a time (cmp 960h, RVA 0x3B75D0), so each batch of 400 lines is one draw. For each batch, space is allocated in VCacheVB, the vertices are copied in, and then ID3D11DeviceContext::Draw(count, offset / stride) is called.

    3. VCacheVB is created at renderer init with 0x200000 bytes per buffer (RVA 0x283C17), and there are [renderer+0x1638] + 1 buffers. They are rotated by RVA 0x295E70, which calls the ring advance at RVA 0x29F9D0 with resetOffset = false.

    Capacity: 2,097,152 / 168 = 12,483 lines per frame, minus everything else drawn through VCacheVB in that frame (vanilla widgets, text). This matches the onset at about 12,000 lines.

    The bug is in the allocator, RVA 0x29FB90:

    aligned    = align_up(slot.offset, stride)
    free       = capacity - aligned
    slot.used += size + (aligned - slot.offset)          // on wrap the skipped tail (capacity - aligned) is NOT added
    mode       = slot.used < capacity ? WRITE_NO_OVERWRITE : WRITE_DISCARD
    if (size <= free) slot.offset = aligned + size;
    else            { slot.offset = size; aligned = 0; } // wrap to 0
    Map(buffer, aligned, size, mode)
    
    1. The offset isn't reset on rotation, so a frame starts writing at some offset o.

    2. On wrap, the skipped tail T = capacity − aligned isn't added to used. T is smaller than the allocation that caused the wrap: at most 67,200 B (400 lines) for a canvas batch.

    3. After the wrap, maps stay NO_OVERWRITE until used reaches capacity. That happens at o + T instead of at o.

    4. So the range [o, o + T) is overwritten. It holds the first vertices of this frame, whose draws are still queued. Only after that does the mode switch to DISCARD.

    This explains the symptoms:

    • Only the geometry drawn first breaks.

    • It flickers, because the result depends on whether the GPU has already read that range.

    • Splitting lines across several canvases doesn't help, because everything shares one ring.

  • 6wingseraphreplied to a comment(edited)
    about 1 month ago
    6wingseraphreplied to a comment(edited)
    about 1 month ago

    Proposed fix

    Make the allocator a standard D3D11 dynamic ring: map with NO_OVERWRITE while the write doesn't overlap data queued in this frame, and with DISCARD when it would. Only this function changes (plus the same change in its index-buffer twin, see below). Rotation, buffer size and callers stay as they are.

    // slot.used: bytes the write head has advanced in this buffer since the current frame
    //            started using it, or since the last DISCARD
    void* VCacheRing::Alloc(uint count, uint stride, uint* out)   // out[0] = offset, out[1] = generation
    {
        const uint size = count * stride;
        if (size > capacity)
            return nullptr;                                // can never fit; the UI draw path already skips on nullptr
    
        const uint aligned = AlignUp(slot.offset, stride);
        const bool wrap    = aligned + size > capacity;
        const uint start   = wrap ? 0 : aligned;
        const uint advance = (wrap ? capacity - slot.offset : aligned - slot.offset) + size;   // includes the skipped tail
    
        MapMode mode = WRITE_NO_OVERWRITE;
        if (slot.used + advance <= capacity) {
            slot.used += advance;                          // nothing queued this frame is touched
        } else {
            mode = WRITE_DISCARD;                          // fresh copy; queued draws keep the old one
            slot.used = size;
        }
    
        if (mode == WRITE_DISCARD)
            buffer->generation += 2;                       // invalidates every range cached before the discard
        else if (wrap)
            ++buffer->generation;                          // unchanged from current code
    
        slot.offset = start + size;
        if (out) {
            out[0] = start;
            out[1] = buffer->generation;                   // callers that cache store it, as today
        }
        return buffer->Map(start, size, mode);
    }
    

    Why this is the stable option:

    • Standard D3D11 pattern. This is the documented pattern for dynamic buffers: append with NO_OVERWRITE, DISCARD when full. VCacheVB is already D3D11_USAGE_DYNAMIC, and the engine's Map already turns mode 4 into D3D11_MAP_WRITE_DISCARD.

    • No change for normal frames. Frames that fit in the ring take the same path as today. Only frames that overflow it behave differently: a frame whose total, including the skipped tail, would reach the capacity now gets a DISCARD instead of overwriting its own data. This also covers the other rings that share this allocator.

    • Caching rings stay correct. The same allocator serves rings whose clients cache vertex ranges across frames (e.g. RVA 0x1279A0, 0x25C160). They re-validate those ranges with RVA 0x29FFF0: gen == cached.gen || (gen == cached.gen + 1 && cached.offset > head). After a DISCARD the new buffer copy no longer holds those ranges, so the generation is advanced by 2, which fails that check for anything cached before. The current code already DISCARDs without changing the generation once used reaches capacity, so this also closes that latent case.

    • The line limit goes away. Above 2 MiB per frame, the cost is one buffer rename per extra 2 MiB, handled by the driver. For example, 30,000 lines ≈ 4.8 MiB → 2 renames. The VCacheVB size becomes a pure performance setting.

    • Index-buffer twin. The index allocator at RVA 0x29FA90 (used by RenderMesh2DContext rings, RVA 0x1AFE30 / 0x1B0B20) has the same wrap bookkeeping. It is not on the CanvasWidget path, but it should get the same change.

  • about 1 month ago
  • dmitriycommented
    about 1 month ago
    dmitriycommented
    about 1 month ago

    Fix please

  • orngcommented
    about 1 month ago
    orngcommented
    about 1 month ago

    I actually replaced my graphics card because of this, I thought I was having problems with it, but turns out...

  • 6wingseraphcommented
    about 1 month ago
    6wingseraphcommented
    about 1 month ago

    @l909 Did you pass on the new information?

  • l909
    Moderator
    commented
    about 1 month ago
    l909
    Moderator
    commented
    about 1 month ago

    yes

  • about 1 month ago
  • l909
    changed state to
    CONFIRMED INTERNALLY
    about 1 month ago

You are not signed in. Please sign in to see more details and to reply.

State

Confirmed internally
Issue key
DZG-839
Access
Public
Space
DayZ
Project
Stable Feedback
Creator
6wingseraph
Created
Sep 19, 2026
Views
273