Drawing 3D Debug Lines (ULineBatchComponent)¶
The one debug-draw path still live in the SC6 shipping build is UWorld's three
ULineBatchComponent instances. Append FBatchedLine entries to the BatchedLines
TArray at component +0x808 and the engine renders them every frame.
At a glance¶
- Use
ForegroundLineBatcheratUWorld+0x50— no depth test, always on top. - Append to
ULineBatchComponent+0x808 BatchedLines({Data, Num, Max}at+0x808/+0x810/+0x814). - Allocate through the game's
GMalloc(DAT_0x1971C8);FMemory::Reallocat0xD51430. - Mark dirty with
UActorComponent::MarkRenderStateDirty @ 0x1D4E910after the append, or rely on the lifetime-sweep auto-dirty (setRemainingLifeTime ≈ 0.1fper entry and the component's tick will re-mark itself a few frames later).
What's stripped vs what's live¶
| Path | Status |
|---|---|
UKismetSystemLibrary::DrawDebugLine |
UFunction registered (0x142558090), exec handler unbound — silent no-op |
DrawDebugBox |
UFunction constructor only (Z_Construct_UFunction_DrawDebugBox @ 0x142552640); implementation gone via UE_BUILD_SHIPPING |
ULuxTraceDataAsset::bDebugDrawTrace* |
UPROPERTY metadata only; zero consumers |
UWorld::Exec("FLUSHPERSISTENTDEBUGLINES") → ULineBatchComponent::Flush |
Live in shipping. Routes through the component as expected. |
FBatchedLine UScriptStruct |
Live |
Scene proxy BatchedLines rasterisation |
Live, every frame |
For the full inventory of dev-left-behind hooks (including ULuxDev*Setting classes and
the per-tick move-VM debug text buffer), see Dev / Debug Hooks.
UWorld's three batchers¶
Every UWorld owns three of them, all reachable via fixed offsets:
| Offset | Name | Behaviour |
|---|---|---|
+0x40 |
LineBatcher |
Depth-tested, per-frame |
+0x48 |
PersistentLineBatcher |
Depth-tested; entries persist until FLUSHPERSISTENTDEBUGLINES |
+0x50 |
ForegroundLineBatcher |
No depth test, always on top |
For an always-visible debug overlay, use ForegroundLineBatcher. The other two are only
useful when you specifically want the lines to occlude behind geometry or persist across
frames.
Depth-tested batchers are mostly useless for hit-volume overlays
The two depth-tested slots (+0x40 / +0x48) sound appealing — "draw the lines but
let geometry occlude them" — but in SC6 the character meshes and stage geometry are
always closer to the camera than the bone-attached hit volumes you want to draw.
As a result, hitbox and hurtbox lines spend most of every match hidden behind
characters, weapons, and props. HorseMod tried exposing the depth-tested slot as a
Default UI option and ended up hiding it again, because the overlay was visible
only for the few frames per round when nothing stood between the camera and the
chara. ForegroundLineBatcher (+0x50) is the only practical choice for an
always-visible hitbox overlay.
auto* world = any_uobject->GetWorld(); // any UObject works
auto** slot = reinterpret_cast<UObject**>(
reinterpret_cast<uint8_t*>(world) + 0x50); // ForegroundLineBatcher
auto* foreground_batcher = *slot; // ULineBatchComponent*
source:
Z_Construct_UClass_UWorld @ 0x1428a5b90registers the three UProperties withsize=8(pointer) at offsets0x40/0x48/0x50.
ULineBatchComponent layout¶
Only the BatchedLines array matters for drawing. Confirmed via the Flush function:
| Offset | Type | Name |
|---|---|---|
+0x808 |
FBatchedLine* |
BatchedLines.Data |
+0x810 |
int32 |
BatchedLines.Num |
+0x814 |
int32 |
BatchedLines.Max |
+0x818 |
FBatchedPoint* |
BatchedPoints.Data |
+0x820 |
int32 |
BatchedPoints.Num |
+0x824 |
int32 |
BatchedPoints.Max |
+0x830 |
FBatchedMesh* |
BatchedMeshes.Data |
+0x838 |
int32 |
BatchedMeshes.Num |
+0x83C |
int32 |
BatchedMeshes.Max |
| total class size | 0x850 |
source:
ULineBatchComponent::Flush @ 0x141d774c0zeroes+0x810 / +0x820 / +0x838and reallocates the three data arrays at+0x808 / +0x818 / +0x830.
FBatchedLine¶
Per-line POD, 0x34 (52) bytes:
struct FBatchedLine {
FVector Start; // +0x00 — 3 × float
FVector End; // +0x0C — 3 × float
FLinearColor Color; // +0x18 — 4 × float (RGBA)
float Thickness; // +0x28
float RemainingLifeTime; // +0x2C
uint8_t DepthPriority; // +0x30 — ESceneDepthPriorityGroup
uint8_t _pad[3]; // +0x31..+0x33 (natural align to 4)
};
static_assert(sizeof(FBatchedLine) == 0x34, "layout guard");
Alignment trap when defining similar structs in your mod
Don't use a uint64_t for an opaque 8-byte field in a UE4 param-block struct (for
example ReceiveGetWeaponTip's inEvent). A uint64_t forces alignof = 8 on the
whole struct and the compiler pads sizeof up to the next 8-byte boundary — which
won't match UE4's 4-byte-packed layout. Use uint8_t[8] instead so alignof stays
at 4.
source:
Z_Construct_UScriptStruct_FBatchedLine @ 0x1425cfcd0registers the struct as"BatchedLine"withsize = 0x34.
Allocator compatibility¶
The array at +0x808 was allocated by the game's own FMemory::Malloc, which is a thin
wrapper over GMalloc (at DAT_1441971c8):
FMemory::Realloc(ptr, size, align)
-> (*GMalloc)->vtable[0x18](GMalloc, ptr, size, align) // FMalloc::Realloc
Resolve the game's GMalloc data symbol (DAT_1441971C8) from your native DLL
and use that FMalloc** to grow / reallocate the array. When the component's own
Flush later tries to free the buffer, it calls the same FMalloc::Realloc on
the same instance and sees a normal allocation header.
FMalloc** g_pGMalloc = ResolveGMalloc(); // DAT_1441971C8
void reserveAtLeast(TArrHdr* arr, int32_t needed_count) {
if (arr->Max >= needed_count) return;
int32_t new_max = (arr->Max == 0) ? 64
: (needed_count + (needed_count / 4) + 16);
arr->Data = (*g_pGMalloc)->Realloc(
arr->Data,
static_cast<size_t>(new_max) * sizeof(FBatchedLine),
alignof(FBatchedLine));
arr->Max = new_max;
}
The scene-proxy dirty problem¶
UActorComponent::MarkRenderStateDirty() is what tells UE4 the scene proxy needs a
rebuild to pick up new BatchedLines. It's not a UFunction (not reachable by
reflection), but the C++ implementation is not inlined in SC6 shipping — it lives
at a known address and can be called directly.
Location:
UActorComponent::MarkRenderStateDirty @ 0x141d4e910(RVA0x1D4E910). Ghidra had this mis-labeled asUActorComponent_ConditionalRegisterComponentInternal; the body matches the canonical UE4 pattern — gate onRegistrationState == RS_REGISTERED(bits 0x3 atUActorComponent+0x188), setbRenderStateDirty(bit 0x20 at+0x188), then callMarkForNeededEndOfFrameRecreate @ 0x141d4e7b0. Confirmed by the xref fromUSceneComponent::SetVisibility @ 0x141dad60awhich calls it unconditionally.
If you append to BatchedLines but never mark the component dirty, the scene proxy
keeps drawing stale data. Four workable mitigations, in roughly descending order of
reliability:
-
Direct call to
MarkRenderStateDirty(recommended). After your per-frame appends, call the function above on the batcher pointer. No UFunction lookup, no vtable scan — just a plain function pointer at a fixed RVA. -
Short-lifetime trick. Append every line with
RemainingLifeTime = 0.05f .. 0.10f. The component's ownTickComponentruns the lifetime sweep every frame; when a line expires the sweep removes it and, at its end, callsMarkRenderStateDirty(the same function at0x141d4e910) viaFlush. Re-appending every frame keeps the proxy in a steady state — constantly rebuilt and always holding fresh data. The cost is a one-to-three frame delay when the overlay is toggled off, while the last lines finish their lifetime naturally. Works without binding any direct function pointer. - Toggle component visibility.
USceneComponent::SetVisibility(bNewVisibility, bPropagateToChildren)is a UFunction that callsMarkRenderStateDirtyon its tail path (confirmed by the xref fromSetVisibilityto0x141d4e910). Slightly hacky, but UFunction-only. - Locate
MarkRenderStateDirtyvia vtable pattern scan. An engine-version-robust fallback; unnecessary for SC6 since option 1 gives a direct address.
Minimal example¶
POD type aliases:
struct FVec3 { float X, Y, Z; };
struct FLinColor { float R, G, B, A; };
struct TArrHdr { void* Data; int32_t Num; int32_t Max; };
struct FBatchedLine {
FVec3 Start;
FVec3 End;
FLinColor Color;
float Thickness;
float RemainingLifeTime;
uint8_t DepthPriority;
uint8_t _pad[3];
};
One-time priming (call with any UObject — a cockpit widget, a chara, anything with a world):
UObject* foreground_batcher = nullptr;
void prime(UObject* pivot) {
auto* world = pivot->GetWorld();
if (!world) return;
auto** slot = reinterpret_cast<UObject**>(
reinterpret_cast<uint8_t*>(world) + 0x50);
foreground_batcher = *slot;
}
Per-frame append:
constexpr float kLifetime = 0.10f;
void drawLine(const FVec3& a, const FVec3& b, const FLinColor& col, float thickness) {
if (!foreground_batcher) return;
auto* arr = reinterpret_cast<TArrHdr*>(
reinterpret_cast<uint8_t*>(foreground_batcher) + 0x808);
reserveAtLeast(arr, arr->Num + 1);
auto* entry = static_cast<FBatchedLine*>(arr->Data) + arr->Num;
entry->Start = a;
entry->End = b;
entry->Color = col;
entry->Thickness = (thickness > 0.0f) ? thickness : 1.0f;
entry->RemainingLifeTime = kLifetime;
entry->DepthPriority = 0;
entry->_pad[0] = entry->_pad[1] = entry->_pad[2] = 0;
arr->Num += 1;
}
Call drawLine from any game-thread hook (e.g. a CockpitBase_C::Update pre-hook) for
every line you want visible on the current frame. The component's tick does the rest.
Key binary addresses (SC6 Steam, image base 0x140000000)¶
| Symbol | RVA | Description |
|---|---|---|
Z_Construct_UClass_UWorld |
0x2A5B90 |
Registers the three LineBatcher UProperties (+0x40, +0x48, +0x50). |
Z_Construct_UClass_ULineBatchComponent |
0x25C9590 |
Class size 0x850; confirms the layout. |
Z_Construct_UScriptStruct_FBatchedLine |
0x25CFCD0 |
Confirms 0x34-byte struct. |
ULineBatchComponent::Flush |
0x1D774C0 |
Zeros Num at +0x810 / +0x820 / +0x838; confirms BatchedLines.Data at +0x808. |
UActorComponent::MarkRenderStateDirty |
0x1D4E910 |
Sets bRenderStateDirty (bit 0x20 at +0x188) and queues an end-of-frame proxy rebuild. Call directly after appending to BatchedLines. |
UActorComponent::MarkForNeededEndOfFrameRecreate |
0x1D4E7B0 |
Tail-called from MarkRenderStateDirty; do not call directly. |
UWorld::Exec("FLUSHPERSISTENTDEBUGLINES") |
0x21B9ED0 |
Routes to ULineBatchComponent::Flush on UWorld+0x48; proves the pipeline is live in shipping. |
GMalloc (data) |
DAT_0x1971C8 |
The game's FMalloc**. |
FMemory::Realloc |
0xD51430 |
Thin wrapper over GMalloc::vtable[0x18]. |