Skip to content

Commit e4205e9

Browse files
committed
feat: rebuild profiler with Vide and extensible traffic layers
1 parent 369aa50 commit e4205e9

43 files changed

Lines changed: 4365 additions & 1834 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,21 @@ PacketProfiler
44

55
<img src="https://user-images.githubusercontent.com/45090858/181864347-96269b03-25d7-475a-a7ad-352e1955fd4f.png" width="100" height="100" /></h1>
66

7-
<div align="center">Remote packet analyzer tool for Roblox</div>
7+
<div align="center">Extensible replication profiler for Roblox</div>
88

99
<div>&nbsp;</div>
1010

1111
## Introduction
1212

13-
Packet Profiler is a plugin which allows you to accurately read remote data sent by different contexts. Unlike the vague and uninformative Stats windows which only show the current KB/s receive and send rates, this plugin allows you to accurately see packet data each frame, along with precise byte size information.
13+
Packet Profiler shows the volume and contents of replication traffic frame by frame. Remote traffic is included out of the box, while a public layer API lets each game add its own sources, such as instance replication, custom networking libraries, state synchronization, or domain-specific metrics.
14+
15+
When addons register more traffic sources, the timeline can show all layers together or isolate any individual layer without discarding the combined capture. The view selector stays hidden when remotes are the only registered layer. Selecting a frame opens a breakdown of the calls recorded for the active view.
16+
17+
The **Top spikes** view retains the largest frames from the full profiler session, even after they leave the rolling timeline. It can show the top 5, 10, or 20 frames for the combined view or any registered layer. The details widget becomes a largest-first spike inspector with comparable magnitude bars, contributor summaries, expandable packet data, bulk expand/collapse, and a frozen ranking mode for reading while capture continues.
18+
19+
The rolling timeline uses timestamped 60 Hz buckets, so its 256 bars consistently cover about 4.27 seconds. When rendering slows down, the capture clock commits every elapsed bucket instead of collapsing them into one rendered frame. Extensions can read the bucket duration with `GetFrameInterval()`.
20+
21+
The interface is rendered with Vide. The timeline is a mirrored cyclic strip: each commit writes one persistent bar slot and shifts the strip, so existing heights remain untouched until their frames genuinely leave the 256-frame window. This avoids full-chart reconciliation and render-frame polling.
1422

1523
![image](https://github.com/Pyseph/PacketProfiler/assets/45090858/9a68dc84-0ad5-4358-9e79-8750d9e40b2e)
1624
![image](https://user-images.githubusercontent.com/45090858/181864397-9b9d5e82-72fe-4bee-b29f-9b8e5a16aa4d.png)
@@ -24,6 +32,84 @@ This BindableEvent may also be used to log RemoteEvents that have been created a
2432
Some games may rename their RemoteEvents for network & encoding purposes (such as those that only use 1 RemoteEvent for everything). You can rename RemoteEvents by adding a `RemoteName.profiler` ModuleScript anywhere in ReplicatedStorage, whose return must be a function. This function will be called with two arguments: the invoked RemoteEvent, and the RemoteEvent's first argument. The function must return a string, which will be used as the RemoteEvent's name in the profiler.
2533
**NOTE**: adding this will cause the profiler to use the module to rename all remote events, so make sure to return the original name if you don't want to rename a specific RemoteEvent.
2634

35+
## Extension API
36+
37+
Requiring the in-game package returns the running profiler. In a Studio client with the PacketProfiler plugin installed, it instead returns a bridge to the plugin so game-defined layers appear in the plugin window rather than opening a second profiler UI. The installed plugin connects its Edit and Play client instances through `PluginConnectionService`; no server relay or script-injection permission is required.
38+
39+
Layers describe a source of traffic and receive an `Emit` function. The value returned by `Start` is used as the layer's cleanup function.
40+
41+
```luau
42+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
43+
local PacketProfiler = require(ReplicatedStorage.Packages.PacketProfiler)
44+
45+
local registration = PacketProfiler:RegisterLayer({
46+
Id = "instance-audit",
47+
Name = "Instance audit",
48+
Color = Color3.fromHex("#B98CFF"),
49+
Description = "Replicated instance creation",
50+
51+
Start = function(context)
52+
local connection = workspace.DescendantAdded:Connect(function(instance)
53+
context.Emit({
54+
Name = instance.ClassName,
55+
Size = 24,
56+
Data = {
57+
Path = instance:GetFullName(),
58+
ClassName = instance.ClassName,
59+
},
60+
Metadata = {
61+
Instance = instance,
62+
},
63+
})
64+
end)
65+
66+
return function()
67+
connection:Disconnect()
68+
end
69+
end,
70+
})
71+
72+
-- Remove the layer and stop its capture adapter later.
73+
registration:Disconnect()
74+
```
75+
76+
Every packet needs a display `Name` and non-negative byte `Size`. `Data`, `RawData`, `Metadata`, and `Timestamp` are optional. Metadata is deliberately layer-defined so addons can retain references or structured context without changing the profiler core.
77+
78+
Studio-bridged descriptors and packets are serialized across a `PluginConnection`, so keep their fields to primitives, tables, Roblox vectors and colors, enum items, and Instances. Functions remain client-side in `Start` and are never sent to the plugin. Set `IncludeInAll = false` for a count-based or non-byte layer that should have its own view without changing the combined byte graph. `EntryName`, `Unit`, `DefaultMaxFrameValue`, and `ScaleValues` customize that view's labels and scale.
79+
80+
### Hooks and addons
81+
82+
Hooks can observe or transform traffic without owning a layer:
83+
84+
```luau
85+
local hook = PacketProfiler:RegisterHook("BeforeRecord", function(packet)
86+
if packet.Metadata and packet.Metadata.IgnoreInProfiler then
87+
return false
88+
end
89+
90+
return packet
91+
end)
92+
```
93+
94+
Supported stages are `BeforeRecord`, `AfterRecord`, and `FrameCommitted`. Returning `false` from `BeforeRecord` drops a packet; returning a table replaces it. Addons can group several layers and hooks behind one lifecycle:
95+
96+
```luau
97+
local addon = PacketProfiler:RegisterAddon({
98+
Id = "my-network-suite",
99+
Install = function(profiler)
100+
local layer = profiler:RegisterLayer(MyLayer)
101+
local hook = profiler:RegisterHook("AfterRecord", MyObserver)
102+
103+
return function()
104+
layer:Disconnect()
105+
hook:Disconnect()
106+
end
107+
end,
108+
})
109+
```
110+
111+
The Studio bridge exposes `RegisterLayer`, `RegisterHook`, `Record`, `RegisterAddon`, and `Destroy`. Its `BeforeRecord` and `AfterRecord` hooks run in the game client before packets are batched to the plugin. The full in-game profiler additionally exposes `SetView`, `SetPaused`, `SelectFrame`, `GetLayer`, `GetLayers`, `GetFramePackets`, `GetTopFrames`, frame hooks, and UI signals; view and frame state remain owned by the plugin. `GetTopFrames(view, limit)` returns ranked `{ Frame, Size }` entries retained from the current session.
112+
27113
## In-game profiling
28114
Sometimes, simply profiling in studio may not be enough. As such, you can use this plugin **in-game**! Simply grab the latest release `.rbxm` file and place it inside `StarterPlayerScripts`. You can then open the UI through `Ctrl + F5`, and pause/unpause through `Ctrl + P`.
29115
<https://github.com/PysephWasntAvailable/PacketProfiler/releases/>

‎build/main.luau‎

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,27 @@
1-
local RunService = game:GetService("RunService")
2-
local Players = game:GetService("Players")
3-
if RunService:IsStudio() then
4-
return nil
5-
end
6-
7-
local LocalPlayer = Players.LocalPlayer
8-
91
local Components = script.Components
102
local Modules = script.Modules
11-
local Packages = require(Modules.Packages)
123

13-
local Roact = require(Packages.Directory.Roact)
144
local MainPlugin = require(Components.MainPlugin)
5+
local Profiler = require(Modules.Profiler)
6+
local RemoteLayer = require(Modules.RemoteLayer)
7+
local StudioBridgeClient = require(Modules.StudioBridgeClient)
8+
9+
local studioBridge = StudioBridgeClient.new()
10+
if studioBridge then
11+
return studioBridge
12+
end
13+
14+
local profiler = Profiler.new()
15+
profiler:RegisterLayer(RemoteLayer)
16+
profiler:Start()
1517

16-
local Main = Roact.createElement(MainPlugin)
18+
local unmount = MainPlugin({
19+
Profiler = profiler,
20+
})
21+
22+
profiler.Unmount = function()
23+
unmount()
24+
profiler:Destroy()
25+
end
1726

18-
Roact.mount(Main, LocalPlayer.PlayerGui, "PacketProfiler")
19-
return nil
27+
return profiler

‎build/main.server.luau‎

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,44 @@ end
66
local Plugin = script:FindFirstAncestorOfClass("Plugin")
77

88
local Components = script.Components
9-
local Packages = script.Packages
9+
local Modules = script.Modules
10+
11+
local PluginConnectionService = game:GetService("PluginConnectionService")
12+
local isTestDataModel = PluginConnectionService:CanHaveConnectionType(Enum.PluginConnectionTargetType.Edit)
13+
if isTestDataModel and not RunService:IsClient() then
14+
return
15+
end
1016

11-
local Roact = require(Packages.Roact)
1217
local MainPlugin = require(Components.MainPlugin)
18+
local Profiler = require(Modules.Profiler)
19+
local RemoteLayer = require(Modules.RemoteLayer)
20+
local StudioBridge = require(Modules.StudioBridge)
1321

1422
local Toolbar = Plugin:CreateToolbar("Packet Profiler")
1523

1624
local PacketProfiler = Toolbar:CreateButton("Open Profiler", "Open Profiler Graph", "rbxassetid://10283407097")
1725
local PacketChart = Toolbar:CreateButton("Open Chart", "Open Pie Chart", "rbxassetid://10283406077")
1826

19-
local Main = Roact.createElement(MainPlugin, {
20-
PacketProfiler = PacketProfiler,
21-
PacketChart = PacketChart,
22-
})
27+
local profiler = Profiler.new()
28+
profiler:RegisterLayer(RemoteLayer)
29+
profiler:Start()
30+
local stopStudioBridge
31+
if isTestDataModel then
32+
local StudioBridgeTransport = require(Modules.StudioBridgeTransport)
33+
stopStudioBridge = StudioBridgeTransport.Start(profiler)
34+
else
35+
stopStudioBridge = StudioBridge.Start(profiler)
36+
end
2337

24-
local Handle = Roact.mount(Main, nil, "PacketProfiler")
38+
local unmount = MainPlugin({
39+
PacketProfilerButton = PacketProfiler,
40+
PacketChartButton = PacketChart,
41+
Plugin = Plugin,
42+
Profiler = profiler,
43+
})
2544

2645
Plugin.Unloading:Connect(function()
27-
Roact.unmount(Handle)
28-
end)
46+
stopStudioBridge()
47+
unmount()
48+
profiler:Destroy()
49+
end)

‎build/packagedev.client.luau‎

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
1-
local Players = game:GetService("Players")
2-
local LocalPlayer = Players.LocalPlayer
3-
41
local Components = script.Components
52
local Modules = script.Modules
63
local Packages = require(Modules.Packages)
74
Packages.IsPlugin = false
85

9-
local Roact = require(Packages.Directory.Roact)
106
local MainPlugin = require(Components.MainPlugin)
7+
local Profiler = require(Modules.Profiler)
8+
local RemoteLayer = require(Modules.RemoteLayer)
9+
local StudioBridgeClient = require(Modules.StudioBridgeClient)
10+
11+
local studioBridge = StudioBridgeClient.new()
12+
if studioBridge then
13+
return
14+
end
1115

12-
local Main = Roact.createElement(MainPlugin)
16+
local profiler = Profiler.new()
17+
profiler:RegisterLayer(RemoteLayer)
18+
profiler:Start()
1319

14-
Roact.mount(Main, LocalPlayer.PlayerGui, "PacketProfiler")
20+
MainPlugin({
21+
Profiler = profiler,
22+
})

‎buildplugin.project.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,4 @@
1212
"$path": "Packages"
1313
}
1414
}
15-
}
15+
}

‎selene.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,4 @@
1-
std = "roblox"
1+
std = "roblox"
2+
3+
[rules]
4+
mixed_table = "allow"

‎src/Components/AnimatedButton.luau‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
local PacketProfiler = script.Parent.Parent
2+
local Packages = require(PacketProfiler.Modules.Packages)
3+
local Vide = require(Packages.Directory.Vide)
4+
5+
local create = Vide.create
6+
local read = Vide.read
7+
local source = Vide.source
8+
local spring = Vide.spring
9+
10+
local function AnimatedButton(props)
11+
local hovered = source(false)
12+
local pressed = source(false)
13+
local transparency = spring(function()
14+
local selected = read(props.Selected) == true
15+
if selected then
16+
return props.SelectedTransparency or 0.78
17+
elseif pressed() then
18+
return 0.74
19+
elseif hovered() then
20+
return 0.82
21+
end
22+
return 1
23+
end, 0.12, 1)
24+
25+
return create("TextButton")({
26+
AutoButtonColor = false,
27+
AutoLocalize = false,
28+
BackgroundColor3 = function()
29+
return read(props.AccentColor)
30+
end,
31+
BackgroundTransparency = transparency,
32+
BorderSizePixel = 0,
33+
Font = Enum.Font.BuilderSansMedium,
34+
LayoutOrder = props.LayoutOrder,
35+
Size = function()
36+
return read(props.Size) or UDim2.fromOffset(80, 26)
37+
end,
38+
Text = function()
39+
return read(props.Text)
40+
end,
41+
TextColor3 = function()
42+
return read(props.TextColor)
43+
end,
44+
TextSize = props.TextSize or 13,
45+
TextTruncate = props.TextTruncate or Enum.TextTruncate.AtEnd,
46+
47+
Activated = props.OnClick,
48+
MouseEnter = function()
49+
hovered(true)
50+
end,
51+
MouseLeave = function()
52+
hovered(false)
53+
pressed(false)
54+
end,
55+
MouseButton1Down = function()
56+
pressed(true)
57+
end,
58+
MouseButton1Up = function()
59+
pressed(false)
60+
end,
61+
62+
create("UICorner")({
63+
CornerRadius = UDim.new(0, 6),
64+
}),
65+
})
66+
end
67+
68+
return AnimatedButton

0 commit comments

Comments
 (0)