From cc841d96e245effb0fadb546af91d55a49413859 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 00:57:11 +0700 Subject: [PATCH 1/7] feat(benchmark): integrate Suphi-Packet into benchmarking harness --- benchmark/default.project.json | 7 +++-- benchmark/src/server/init.server.luau | 5 ++-- benchmark/src/shared/modes/packet.luau | 39 ++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 benchmark/src/shared/modes/packet.luau diff --git a/benchmark/default.project.json b/benchmark/default.project.json index 6edd285..398dd6e 100644 --- a/benchmark/default.project.json +++ b/benchmark/default.project.json @@ -28,11 +28,14 @@ "$className": "RemoteEvent" } }, - "Packages": { - "$path": "../Packages", + "DevPackages": { + "$path": "../DevPackages", "Satset": { "$path": "../src" } + }, + "SuphiPacket": { + "$path": "../references/Suphi-Packet/Packet" } } } diff --git a/benchmark/src/server/init.server.luau b/benchmark/src/server/init.server.luau index 784d52f..cfeaaef 100644 --- a/benchmark/src/server/init.server.luau +++ b/benchmark/src/server/init.server.luau @@ -1,7 +1,7 @@ -- Benchmark server. Receives data from all modes and validates correctness. local ReplicatedStorage = game:GetService("ReplicatedStorage") -local Satset = require(ReplicatedStorage.Packages.Satset) +local Satset = require(ReplicatedStorage.DevPackages.Satset) Satset.start() local Benches = require(ReplicatedStorage.Shared.benches) @@ -75,7 +75,8 @@ for name, data in Benches do local event = events[name] local callback = onReceive(tool) - if tool == "roblox" then + if tool == "roblox" or tool == "packet" then + -- Roblox and Suphi-Packet use .OnServerEvent event.OnServerEvent:Connect(callback) elseif tool == "bridgenet2" or tool == "warp" then -- BridgeNet2 and Warp use standard :Connect() diff --git a/benchmark/src/shared/modes/packet.luau b/benchmark/src/shared/modes/packet.luau new file mode 100644 index 0000000..ca8f134 --- /dev/null +++ b/benchmark/src/shared/modes/packet.luau @@ -0,0 +1,39 @@ +-- Suphi Packet benchmark mode. +-- Wraps each Packet object so `fire` is a self-bound closure, +-- matching the benchmark harness convention (event.fire or event.Fire). +local Packet = require(game:GetService("ReplicatedStorage").SuphiPacket) + +local function wrap(pkt) + return { + fire = function(data) pkt:Fire(data) end, + Fire = function(data) pkt:Fire(data) end, + OnServerEvent = pkt.OnServerEvent, + } +end + +return { + Booleans = wrap(Packet("Booleans", {Packet.Boolean8})), + Entities = wrap(Packet("Entities", { + { + id = Packet.NumberU8, + x = Packet.NumberU8, + y = Packet.NumberU8, + z = Packet.NumberU8, + orientation = Packet.NumberU8, + animation = Packet.NumberU8, + } + })), + Vectors = wrap(Packet("Vectors", {Packet.Vector3F32})), + Strings = wrap(Packet("Strings", {Packet.String})), + Mixed = wrap(Packet("Mixed", { + id = Packet.NumberU8, + health = Packet.NumberU8, + position = Packet.Vector3F32, + velocity = Packet.Vector3F32, + name = Packet.String, + isRunning = Packet.Boolean8, + animation = Packet.NumberU8, + team = Packet.NumberU8, + })), + SingleValue = wrap(Packet("SingleValue", Packet.NumberU8)), +} From c2c013f88d399f56bf2a3231ebb29cf3c8e30418 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 00:57:26 +0700 Subject: [PATCH 2/7] refactor(benchmark): migrate package paths to DevPackages --- benchmark/src/client/init.client.luau | 2 +- benchmark/src/shared/modes/bridgenet2.luau | 2 +- benchmark/src/shared/modes/bytenet.luau | 2 +- benchmark/src/shared/modes/satset.luau | 2 +- benchmark/src/shared/modes/warp.luau | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/benchmark/src/client/init.client.luau b/benchmark/src/client/init.client.luau index 5cc3436..f139931 100644 --- a/benchmark/src/client/init.client.luau +++ b/benchmark/src/client/init.client.luau @@ -7,7 +7,7 @@ local RunService = game:GetService("RunService") local StarterGui = game:GetService("StarterGui") local Stats = game:GetService("Stats") -local Satset = require(ReplicatedStorage.Packages.Satset) +local Satset = require(ReplicatedStorage.DevPackages.Satset) Satset.start() local MAXIMUM_FRAMERATE = 60 diff --git a/benchmark/src/shared/modes/bridgenet2.luau b/benchmark/src/shared/modes/bridgenet2.luau index 7654603..62f00ff 100644 --- a/benchmark/src/shared/modes/bridgenet2.luau +++ b/benchmark/src/shared/modes/bridgenet2.luau @@ -1,6 +1,6 @@ -- BridgeNet2 benchmark mode (v1.0.0 API). -- BridgeNet2 uses ReferenceBridge with string identifiers. -local BridgeNet2 = require(game:GetService("ReplicatedStorage").Packages.BridgeNet2) +local BridgeNet2 = require(game:GetService("ReplicatedStorage").DevPackages.BridgeNet2) local Benches = require(game:GetService("ReplicatedStorage").Shared.benches) diff --git a/benchmark/src/shared/modes/bytenet.luau b/benchmark/src/shared/modes/bytenet.luau index 44ad4b7..d8810f2 100644 --- a/benchmark/src/shared/modes/bytenet.luau +++ b/benchmark/src/shared/modes/bytenet.luau @@ -1,5 +1,5 @@ -- ByteNet benchmark mode (v0.4.6 API). -local ByteNet = require(game:GetService("ReplicatedStorage").Packages.ByteNet) +local ByteNet = require(game:GetService("ReplicatedStorage").DevPackages.ByteNet) -- ByteNet 0.4.6 requires the schema to be in the 'value' field. return ByteNet.defineNamespace("Benchmark", function() diff --git a/benchmark/src/shared/modes/satset.luau b/benchmark/src/shared/modes/satset.luau index 39fa451..bef8cb8 100644 --- a/benchmark/src/shared/modes/satset.luau +++ b/benchmark/src/shared/modes/satset.luau @@ -1,6 +1,6 @@ -- Satset benchmark mode. local RunService = game:GetService("RunService") -local Satset = require(game:GetService("ReplicatedStorage").Packages.Satset) +local Satset = require(game:GetService("ReplicatedStorage").DevPackages.Satset) local Types = Satset.Types -- We don't call Satset.start() here anymore. diff --git a/benchmark/src/shared/modes/warp.luau b/benchmark/src/shared/modes/warp.luau index 572df2c..59e7a61 100644 --- a/benchmark/src/shared/modes/warp.luau +++ b/benchmark/src/shared/modes/warp.luau @@ -1,7 +1,7 @@ -- Warp benchmark mode (v1.0.14 API). -- Warp uses string identifiers. Server/Client are separate constructors. local RunService = game:GetService("RunService") -local Warp = require(game:GetService("ReplicatedStorage").Packages.Warp) +local Warp = require(game:GetService("ReplicatedStorage").DevPackages.Warp) local Benches = require(game:GetService("ReplicatedStorage").Shared.benches) From 8de5dafdb5770249e1b6ecfd6611c7ea7c83bbbe Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 01:10:31 +0700 Subject: [PATCH 3/7] docs(benchmark): update performance results and add Suphi-Packet comparison --- .gitignore | 3 +- benchmark/Benchmarks.md | 72 ++-- benchmark/result.json | 720 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 762 insertions(+), 33 deletions(-) create mode 100644 benchmark/result.json diff --git a/.gitignore b/.gitignore index 2b3e1e9..31f2e2b 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ wally.lock # Sourcemap sourcemap.json Packages/ -references/ \ No newline at end of file +references/ +DevPackages/ diff --git a/benchmark/Benchmarks.md b/benchmark/Benchmarks.md index a7a44c0..d6a9b9b 100644 --- a/benchmark/Benchmarks.md +++ b/benchmark/Benchmarks.md @@ -1,6 +1,6 @@ # Satset Networking Benchmarks -*Last Updated: 2026-04-30 — v0.1.2 (with buffer bounds checking, xpcall listener protection, and double-start guard)* +*Last Updated: 2026-05-01 — v0.1.2 (with buffer bounds checking, xpcall listener protection, and double-start guard)* This document records the measured performance of **Satset** against native Roblox remotes and other community networking libraries under identical stress conditions. @@ -8,14 +8,14 @@ This document records the measured performance of **Satset** against native Robl **Lower is better.** This measures how much raw network traffic each library generates to transmit the same logical data. -| Benchmark | Native Roblox | BridgeNet2 | ByteNet 0.4.6 | Warp | **Satset** | -| :--- | ---: | ---: | ---: | ---: | ---: | -| **Vectors** (12B × array) | 178,955 | 169,992 | 79 | 0.8\* | **80** | -| **Booleans** (1B × array) | 177,841 | 71,073 | 23 | 0.8\* | **14** | -| **Mixed** (30B struct) | 118,132 | 8,544 | 2.9 | 0.9\* | **5** | -| **Entities** (6B × array) | 300,342 | 82,577 | 43 | 0.7\* | **43** | -| **Strings** (var-len × array) | 134,870 | 168,468 | **CRASHED** | 0.7\* | **107** | -| **SingleValue** (1B) | 1,794 | 722 | 2.5 | 0.8\* | **3** | +| Benchmark | Native Roblox | BridgeNet2 | ByteNet 0.4.6 | Warp | Packet | **Satset** | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Vectors** (12B × array) | 178,955 | 169,992 | 79 | 0.8\* | 78 | **80** | +| **Booleans** (1B × array) | 177,841 | 71,073 | 23 | 0.8\* | 26 | **14** | +| **Mixed** (30B struct) | 118,132 | 8,544 | 2.9 | 0.9\* | 5 | **5** | +| **Entities** (6B × array) | 300,342 | 82,577 | 43 | 0.7\* | 35 | **43** | +| **Strings** (var-len × array) | 134,870 | 168,468 | **CRASHED** | 0.7\* | 106 | **107** | +| **SingleValue** (1B) | 1,794 | 722 | 2.5 | 0.8\* | 2.4 | **3** | \*Warp consistently shows near-zero bandwidth and zero received packets across all tests. This indicates a listener integration issue in the benchmark adapter rather than actual Warp performance. @@ -25,14 +25,14 @@ This document records the measured performance of **Satset** against native Robl **Higher is better.** This shows how much CPU headroom each library leaves for the game while processing network traffic. -| Benchmark | Native Roblox | BridgeNet2 | ByteNet 0.4.6 | Warp | **Satset** | -| :--- | ---: | ---: | ---: | ---: | ---: | -| **Vectors** | 42 | 44 | 60 | 60\* | **37** | -| **Booleans** | 16 | 15 | 22 | 60\* | **15** | -| **Mixed** | 60 | 59 | 16 | 60\* | **60** | -| **Entities** | 16 | 16 | 19 | 60\* | **46** | -| **Strings** | 29 | 36 | — | 60\* | **44** | -| **SingleValue** | 60 | 60 | 60 | 60\* | **60** | +| Benchmark | Native Roblox | BridgeNet2 | ByteNet 0.4.6 | Warp | Packet | **Satset** | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | +| **Vectors** | 49 | 58 | 60 | 60\* | 60 | **38** | +| **Booleans** | 16 | 15 | 22 | 60\* | 15 | **15** | +| **Mixed** | 60 | 60 | 16 | 60\* | 60 | **60** | +| **Entities** | 16 | 16 | 19 | 60\* | 15 | **47** | +| **Strings** | 28 | 38 | — | 60\* | 50 | **46** | +| **SingleValue** | 60 | 60 | 60 | 60\* | 60 | **60** | --- @@ -40,14 +40,14 @@ This document records the measured performance of **Satset** against native Robl Total packets sent vs received over each 10-second test window. -| Benchmark | Roblox (Sent/Recv) | BridgeNet2 | ByteNet | **Satset** | -| :--- | ---: | ---: | ---: | ---: | -| **Vectors** | 428K / 428K | 397K / 397K | 598K / 598K | 369K / 83K | -| **Booleans** | 110K / 110K | 93K / 93K | 219K / 219K | 86K / 20K | -| **Mixed** | 599K / 599K | 598K / 598K | 34K / 34K | 599K / 83K | -| **Entities** | 38K / 38K | 33K / 33K | 191K / 191K | 460K / 30K | -| **Strings** | 263K / 263K | 258K / 258K | 354 / 353 | 437K / 81K | -| **SingleValue** | 600K / 600K | 599K / 599K | 591K / 591K | 590K / 83K | +| Benchmark | Roblox (Sent/Recv) | BridgeNet2 | ByteNet | Packet | **Satset** | +| :--- | ---: | ---: | ---: | ---: | ---: | +| **Vectors** | 464K / 464K | 455K / 455K | 598K / 598K | 600K / 600K | 375K / 83K | +| **Booleans** | 109K / 109K | 97K / 97K | 216K / 216K | 146K / 146K | 84K / 19K | +| **Mixed** | 600K / 600K | 600K / 600K | 36K / 36K | 600K / 600K | 600K / 83K | +| **Entities** | 37K / 37K | 40K / 40K | 191K / 191K | 128K / 128K | 463K / 83K | +| **Strings** | 221K / 221K | 242K / 242K | 347 / 346 | 494K / 494K | 445K / 83K | +| **SingleValue** | 600K / 600K | 599K / 599K | 591K / 591K | 598K / 598K | 599K / 83K | > Satset's lower "Recv" count is by design. The batcher packs hundreds of individual `fireServer` calls into a single remote invocation per frame. The server receives fewer, larger payloads instead of many small ones, which drastically reduces per-packet engine overhead. @@ -55,20 +55,29 @@ Total packets sent vs received over each 10-second test window. ## 4. Technical Analysis -### Satset +### Satset (0.1.2) - **Zero failures.** Every benchmark ran to completion and passed server-side validation. No timeouts, no data corruption. - **Bandwidth leader in structured data.** On the **Booleans** test, Satset used 14 KB/s where Roblox used 178 MB/s — roughly **12,700x more efficient**. Even compared to BridgeNet2 (71 MB/s), Satset is over **5,000x leaner**. -- **Entities performance.** Satset maintained 46 FPS while Roblox and BridgeNet2 both dropped to 15-16 FPS sending the same entity data. This is the direct result of zero-allocation buffer serialization: no tables are created during encode/decode, so the GC stays quiet. +- **Entities performance.** Satset maintained 47 FPS while Roblox and BridgeNet2 both dropped to 15-16 FPS sending the same entity data. This is the direct result of zero-allocation buffer serialization: no tables are created during encode/decode, so the GC stays quiet. - **Hardening overhead.** The newly added `sanitizeFloat`, buffer bounds checks, and `xpcall` listener wrapping had **no measurable impact** on throughput. Bandwidth figures are within margin of error compared to pre-hardening runs. +### Packet (5uphi) (v1.7) + +- **100% delivery** — but only after modification. Zero packet loss on every benchmark; all sent packets were received and validated by the server. +- **Rate limiter had to be disabled.** Packet ships with a hardcoded server-side rate limiter of **8 KB/frame** (`init.lua:270`). Under the benchmark's 1,000-fires-per-frame load, this limit was immediately exceeded and the server **silently dropped all incoming data** — no error, no validation, no results. To produce any meaningful numbers, the limit had to be raised from `8_000` to `10_000_000` bytes. This means Packet's benchmark results reflect a **modified build**, not the out-of-the-box library. +- **Bandwidth efficiency.** Competitive with ByteNet and Satset on binary serialization, producing near-identical KB/s on Vectors (~78), Mixed (~5), and SingleValue (~2.4). This is expected — all three libraries use the same underlying `buffer.write*` primitives. +- **FPS matches ByteNet** on most tests (60 FPS on Vectors, Mixed, SingleValue). However, drops to 15 FPS on Booleans and Entities due to per-element serialization overhead on large arrays. +- **Strings handling.** Maintained 50 FPS on the Strings benchmark — better than Roblox (28), BridgeNet2 (38), and Satset (46). Unlike ByteNet, it did not crash. +- **No batching.** Unlike Satset, Packet fires each serialized payload as an individual `RemoteEvent:FireServer()` call. This gives it a 1:1 sent/received ratio but means the Roblox engine must process every packet separately. In production with many players, this per-packet overhead can become a bottleneck that batching-based libraries like Satset avoid entirely. + ### ByteNet (v0.4.6) -- Excellent bandwidth efficiency on numeric types (competitive with Satset). +- Excellent bandwidth efficiency on numeric types (competitive with Satset and Packet). - **Crashed on Strings** with `Script timeout: exhausted allowed execution time` inside its `dyn_alloc` buffer writer. This is a known limitation of its dynamic buffer growth strategy under high-frequency string serialization. - Severe FPS degradation on the **Mixed** benchmark (16 FPS vs Satset's 60 FPS), suggesting allocation pressure during complex struct encoding. -### BridgeNet2 +### BridgeNet2 (v1.0.0) - Solid batching implementation with good reliability (100% delivery on all tests). - Bandwidth consumption remains high compared to binary-native libraries because it serializes through Roblox's internal encoding rather than raw buffers. @@ -85,6 +94,7 @@ Total packets sent vs received over each 10-second test window. - **Environment:** Roblox Studio, local server with 1 player. - **Duration:** 10-second sustained fire per tool per benchmark. - **Packet rate:** Maximum throughput (fire every frame). +- **Runs:** Each benchmark was executed twice. Results shown are averaged across both runs. - **Metrics:** Bandwidth sampled 6 times during each run. Framerate captured at 6 intervals. Packet counts are cumulative totals. - **Validation:** Server verifies decoded data matches the original input for every library. A library that fails validation is flagged. @@ -92,6 +102,4 @@ Total packets sent vs received over each 10-second test window. ## 6. Raw Data (JSON) -```json -{"Vectors":{"roblox":{"Framerate":[46,48,42,39,39,37],"Sent":428000,"Bandwidth":[87040.93410326087,81283.48188920455,178954.8071808511,283949.3080357143,283949.3080357143,305261.27533783789],"Recieve":428000},"satset":{"Framerate":[37,38,37,36,36,35],"Sent":369000,"Bandwidth":[77.87874322188529,47.13940369455438,79.75733988993878,79.90910091915647,79.90910091915647,79.92751044196052],"Recieve":83056},"bridgenet2":{"Framerate":[49,58,44,43,43,43],"Sent":397000,"Bandwidth":[123741.09375,53015.42054521277,169992.36530172415,169992.36530172415,169992.36530172415,201215.45280612247],"Recieve":397000},"bytenet":{"Framerate":[60,61,60,59,59,58],"Sent":598000,"Bandwidth":[78.59542846679688,61.881290435791019,78.79617309570313,80.38316888324285,80.38316888324285,81.70402789938039],"Recieve":598000},"warp":{"Framerate":[60,61,60,59,59,58],"Sent":598000,"Bandwidth":[0.5147415399551392,0.44034865498542788,0.7243754863739014,0.8366319537162781,0.8366319537162781,0.9511645292413646],"Recieve":0}},"Booleans":{"roblox":{"Framerate":[17,17,16,16,16,16],"Sent":110000,"Bandwidth":[142082.21966911766,76111.29638671875,177840.77205882353,192069.7705078125,192069.7705078125,203953.08363970588],"Recieve":110000},"satset":{"Framerate":[15,16,15,15,15,15],"Sent":86000,"Bandwidth":[11.692818641662598,7.7717337012290959,13.931751251220704,13.931751251220704,13.931751251220704,14.575695037841797],"Recieve":19952},"bridgenet2":{"Framerate":[15,17,15,15,15,15],"Sent":93000,"Bandwidth":[70987.4609375,39258.11236213235,71073.2109375,74063.1015625,74063.1015625,74427.6875],"Recieve":93000},"bytenet":{"Framerate":[22,23,22,21,21,21],"Sent":219000,"Bandwidth":[22.66985633156516,10.74752600296684,23.389805385044644,23.63697832280939,23.63697832280939,27.266837528773718],"Recieve":219000},"warp":{"Framerate":[60,60,60,60,60,58],"Sent":599000,"Bandwidth":[0.5947974920272827,0.4490039348602295,0.8027398586273193,0.9442576169967651,0.9442576169967651,1.0962249903843322],"Recieve":0}},"Mixed":{"roblox":{"Framerate":[60,60,60,60,60,59],"Sent":599000,"Bandwidth":[115569.5859375,44538.390625,118132.140625,118968.6328125,118968.6328125,122733.3515625],"Recieve":599000},"satset":{"Framerate":[60,60,60,60,60,59],"Sent":599000,"Bandwidth":[5.1287617683410648,3.960188388824463,5.188577651977539,5.3851728439331059,5.3851728439331059,5.832243773896815],"Recieve":83056},"bridgenet2":{"Framerate":[60,61,59,59,59,58],"Sent":598000,"Bandwidth":[8285.783491290984,6737.84423828125,8543.777145127118,8564.720934851695,8564.720934851695,8603.907260237069],"Recieve":598000},"bytenet":{"Framerate":[16,16,16,16,16,15],"Sent":34000,"Bandwidth":[2.888798475265503,2.888798475265503,2.888798475265503,2.888798475265503,2.888798475265503,3.27033631503582],"Recieve":34000},"warp":{"Framerate":[60,61,59,59,59,59],"Sent":599000,"Bandwidth":[0.6307408848746878,0.4667607247829437,0.8878631865391966,1.0436007936122054,1.0436007936122054,1.0588467727273197],"Recieve":0}},"Entities":{"roblox":{"Framerate":[16,16,16,16,16,15],"Sent":38000,"Bandwidth":[300342.09375,300342.09375,300342.09375,300342.09375,300342.09375,422848.53515625],"Recieve":38000},"satset":{"Framerate":[46,47,46,46,46,46],"Sent":460000,"Bandwidth":[42.37300375233526,27.61377251666525,42.57011081861413,42.606965769892159,42.606965769892159,42.66785331394362],"Recieve":30160},"bridgenet2":{"Framerate":[16,16,16,16,16,15],"Sent":33000,"Bandwidth":[82576.5380859375,82576.5380859375,82576.5380859375,82576.5380859375,82576.5380859375,106273.328125],"Recieve":33000},"bytenet":{"Framerate":[19,20,19,19,19,19],"Sent":191000,"Bandwidth":[40.747538566589359,18.241161346435548,42.843029624537418,43.57598455328691,43.57598455328691,44.926556035092009],"Recieve":191000},"warp":{"Framerate":[60,60,60,60,60,59],"Sent":599000,"Bandwidth":[0.5736324787139893,0.4431200623512268,0.7422022223472595,0.8961828947067261,0.8961828947067261,1.0356555146686102],"Recieve":0}},"SingleValue":{"roblox":{"Framerate":[60,60,60,60,60,60],"Sent":600000,"Bandwidth":[1716.58984375,1624.1629638671876,1794.3988037109376,1862.046875,1862.046875,1881.8546142578126],"Recieve":600000},"satset":{"Framerate":[60,60,60,59,59,55],"Sent":590000,"Bandwidth":[2.5207481384277345,2.009467840194702,2.5850415229797365,4.586867809295654,4.586867809295654,5.134705042434951],"Recieve":83288},"bridgenet2":{"Framerate":[60,60,60,60,60,59],"Sent":599000,"Bandwidth":[721.5235595703125,562.8475341796875,722.303466796875,723.0374145507813,723.0374145507813,724.3161993511652],"Recieve":599000},"bytenet":{"Framerate":[60,60,60,60,60,57],"Sent":591000,"Bandwidth":[2.4351614399960166,1.9272409677505494,2.486407995223999,2.6438539028167726,2.6438539028167726,3.8990156650543215],"Recieve":591000},"warp":{"Framerate":[60,60,60,60,60,59],"Sent":600000,"Bandwidth":[0.6137112379074097,0.44908496737480166,0.833755612373352,0.9832913390660689,0.9832913390660689,1.0921508073806763],"Recieve":0}},"Strings":{"roblox":{"Framerate":[30,32,29,28,28,27],"Sent":263000,"Bandwidth":[127762.390625,76995.57861328125,134869.80034722223,144698.8415948276,144698.8415948276,160304.7509765625],"Recieve":263000},"satset":{"Framerate":[46,47,44,41,41,36],"Sent":437000,"Bandwidth":[105.8647714010099,64.59545135498047,106.79381999563664,108.3080590289572,108.3080590289572,112.52992109818891],"Recieve":81432},"bridgenet2":{"Framerate":[37,42,36,36,36,27],"Sent":258000,"Bandwidth":[124839.83072916667,53948.13151041667,168467.62957317075,191865.91145833335,191865.91145833335,255821.21527777779],"Recieve":258000},"bytenet":{"Framerate":[],"Sent":354,"Bandwidth":[],"Recieve":353},"warp":{"Framerate":[60,60,60,59,59,59],"Sent":599000,"Bandwidth":[0.567111074924469,0.40372195839881899,0.7347226142883301,0.8855521880974203,0.8855521880974203,0.9359435307777534],"Recieve":0}}} -``` +See [result.json](./result.json) in this directory for the raw benchmark output. diff --git a/benchmark/result.json b/benchmark/result.json new file mode 100644 index 0000000..3a8ebed --- /dev/null +++ b/benchmark/result.json @@ -0,0 +1,720 @@ +{ + "Vectors": { + "roblox": { + "Framerate": [ + 49, + 52, + 47, + 45, + 45, + 40 + ], + "Sent": 467000, + "Bandwidth": [ + 104884.95937499999, + 73894.83455882354, + 227487.9627403846, + 233008.6224489796, + 233008.6224489796, + 237725.36569148937 + ], + "Recieve": 467000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 58 + ], + "Sent": 599000, + "Bandwidth": [ + 0.5308991074562073, + 0.4494810104370117, + 0.7670400738716126, + 0.8999622690266577, + 0.8999622690266577, + 1.019798994064331 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 58, + 60, + 56, + 53, + 53, + 52 + ], + "Sent": 462000, + "Bandwidth": [ + 160204.2689732143, + 45004.98820754717, + 184831.00105932205, + 188017.74245689656, + 188017.74245689656, + 209712.09735576923 + ], + "Recieve": 462000 + }, + "packet": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 78.43345642089844, + 61.49409484863281, + 78.582275390625, + 78.87904357910156, + 78.87904357910156, + 79.2531509399414 + ], + "Recieve": 600000 + }, + "bytenet": { + "Framerate": [ + 60, + 61, + 60, + 59, + 59, + 58 + ], + "Sent": 598000, + "Bandwidth": [ + 77.98338317871094, + 59.3956298828125, + 78.60484313964844, + 79.96454788466632, + 79.96454788466632, + 82.04877129916487 + ], + "Recieve": 598000 + }, + "satset": { + "Framerate": [ + 38, + 38, + 37, + 37, + 37, + 37 + ], + "Sent": 376000, + "Bandwidth": [ + 78.58119362278988, + 48.56520502190841, + 79.96993399955132, + 80.58724068306589, + 80.58724068306589, + 81.26370404217696 + ], + "Recieve": 83056 + } + }, + "Booleans": { + "roblox": { + "Framerate": [ + 16, + 17, + 16, + 16, + 16, + 16 + ], + "Sent": 113000, + "Bandwidth": [ + 200203.00091911766, + 117254.7216796875, + 203539.16360294118, + 213851.9384765625, + 213851.9384765625, + 218852.63671875 + ], + "Recieve": 113000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 601000, + "Bandwidth": [ + 0.41485437750816347, + 0.39247944951057436, + 0.4449799060821533, + 0.4517025351524353, + 0.4517025351524353, + 0.4974772036075592 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 15, + 17, + 15, + 15, + 15, + 15 + ], + "Sent": 98000, + "Bandwidth": [ + 72189.7578125, + 39939.29572610294, + 77040.9453125, + 78245.2109375, + 78245.2109375, + 78614.203125 + ], + "Recieve": 98000 + }, + "packet": { + "Framerate": [ + 15, + 16, + 15, + 15, + 15, + 15 + ], + "Sent": 144000, + "Bandwidth": [ + 25.46214485168457, + 11.299149692058564, + 25.77515983581543, + 25.86599349975586, + 25.86599349975586, + 28.60157012939453 + ], + "Recieve": 144000 + }, + "bytenet": { + "Framerate": [ + 22, + 22, + 20, + 20, + 20, + 20 + ], + "Sent": 211000, + "Bandwidth": [ + 22.084818753329189, + 10.890267979015002, + 23.87845802307129, + 23.892215251922609, + 23.892215251922609, + 26.250240325927736 + ], + "Recieve": 211000 + }, + "satset": { + "Framerate": [ + 15, + 16, + 15, + 15, + 15, + 15 + ], + "Sent": 82000, + "Bandwidth": [ + 13.107977867126465, + 8.091395795345307, + 13.188860893249512, + 13.188860893249512, + 13.188860893249512, + 13.405044555664063 + ], + "Recieve": 19024 + } + }, + "Mixed": { + "roblox": { + "Framerate": [ + 60, + 61, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 97609.03125, + 61312.561475409835, + 109738.9765625, + 110422.328125, + 110422.328125, + 115840.9453125 + ], + "Recieve": 600000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 59 + ], + "Sent": 600000, + "Bandwidth": [ + 0.5904737710952759, + 0.43400758504867556, + 0.7897391319274902, + 0.9627410769462586, + 0.9627410769462586, + 1.0122475583674545 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 8407.3330078125, + 6569.1884765625, + 8426.8212890625, + 8432.294921875, + 8432.294921875, + 8453.5078125 + ], + "Recieve": 600000 + }, + "packet": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 5.127261161804199, + 3.9832065105438234, + 5.1974945068359379, + 5.415558338165283, + 5.415558338165283, + 8.416219711303711 + ], + "Recieve": 600000 + }, + "bytenet": { + "Framerate": [ + 16, + 16, + 16, + 16, + 16, + 15 + ], + "Sent": 35000, + "Bandwidth": [ + 2.751786470413208, + 2.751786470413208, + 2.751786470413208, + 2.751786470413208, + 2.751786470413208, + 3.052605763077736 + ], + "Recieve": 35000 + }, + "satset": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 5.110258102416992, + 3.9467246532440187, + 5.2136993408203129, + 5.3752007484436039, + 5.3752007484436039, + 5.434538841247559 + ], + "Recieve": 83288 + } + }, + "Entities": { + "roblox": { + "Framerate": [ + 16, + 16, + 16, + 16, + 16, + 15 + ], + "Sent": 37000, + "Bandwidth": [ + 316516.59375, + 316516.59375, + 316516.59375, + 316516.59375, + 316516.59375, + 795221.54296875 + ], + "Recieve": 37000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 0.3970705270767212, + 0.36561867594718935, + 0.4102936387062073, + 0.4230411946773529, + 0.4230411946773529, + 0.44207918643951418 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 16, + 16, + 16, + 16, + 16, + 15 + ], + "Sent": 39000, + "Bandwidth": [ + 101796.9140625, + 101796.9140625, + 101796.9140625, + 101796.9140625, + 101796.9140625, + 136866.03125 + ], + "Recieve": 39000 + }, + "packet": { + "Framerate": [ + 15, + 17, + 15, + 15, + 15, + 15 + ], + "Sent": 128000, + "Bandwidth": [ + 35.75397491455078, + 16.775735967299519, + 36.31112289428711, + 36.36629867553711, + 36.36629867553711, + 39.14984130859375 + ], + "Recieve": 128000 + }, + "bytenet": { + "Framerate": [ + 19, + 21, + 19, + 19, + 19, + 19 + ], + "Sent": 192000, + "Bandwidth": [ + 42.61127773084139, + 19.13679804120745, + 42.87232951114052, + 43.86151765522204, + 43.86151765522204, + 44.91154319361637 + ], + "Recieve": 192000 + }, + "satset": { + "Framerate": [ + 48, + 49, + 47, + 47, + 47, + 47 + ], + "Sent": 476000, + "Bandwidth": [ + 42.248239517211917, + 28.713581562042238, + 42.574831374148107, + 42.9941575070645, + 42.9941575070645, + 43.34479960989445 + ], + "Recieve": 83056 + } + }, + "Strings": { + "roblox": { + "Framerate": [ + 25, + 28, + 25, + 24, + 24, + 24 + ], + "Sent": 204000, + "Bandwidth": [ + 131337.8625, + 99514.70424107142, + 139206.665625, + 174043.21180555557, + 174043.21180555557, + 203364.1125 + ], + "Recieve": 204000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 59 + ], + "Sent": 600000, + "Bandwidth": [ + 0.6007622480392456, + 0.4011315107345581, + 0.8173303604125977, + 0.8862589597702026, + 0.8862589597702026, + 1.0340717687445172 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 37, + 38, + 31, + 30, + 30, + 26 + ], + "Sent": 215000, + "Bandwidth": [ + 107800.109375, + 51832.182459677424, + 147608.20945945948, + 210057.83653846154, + 210057.83653846154, + 211705.7685810811 + ], + "Recieve": 215000 + }, + "packet": { + "Framerate": [ + 48, + 50, + 47, + 47, + 47, + 47 + ], + "Sent": 481000, + "Bandwidth": [ + 105.72047233581543, + 72.84988403320313, + 107.63396263122559, + 108.52322948222258, + 108.52322948222258, + 109.51842937063664 + ], + "Recieve": 481000 + }, + "bytenet": { + "Framerate": [], + "Sent": 314, + "Bandwidth": [], + "Recieve": 313 + }, + "satset": { + "Framerate": [ + 44, + 46, + 42, + 38, + 38, + 37 + ], + "Sent": 427000, + "Bandwidth": [ + 106.1367696126302, + 58.26152801513672, + 108.05586678641183, + 109.64547530464505, + 109.64547530464505, + 112.09757624445736 + ], + "Recieve": 83056 + } + }, + "SingleValue": { + "roblox": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 1716.4454345703126, + 1176.636962890625, + 1748.6142578125, + 1766.0179443359376, + 1766.0179443359376, + 1778.7862548828126 + ], + "Recieve": 600000 + }, + "warp": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 601000, + "Bandwidth": [ + 0.569262683391571, + 0.41845929622650149, + 0.7475176453590393, + 0.9281870126724243, + 0.9281870126724243, + 0.9804770350456238 + ], + "Recieve": 0 + }, + "bridgenet2": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 59 + ], + "Sent": 600000, + "Bandwidth": [ + 720.4992065429688, + 565.8301391601563, + 721.9976806640625, + 722.2388305664063, + 722.2388305664063, + 730.8780165850105 + ], + "Recieve": 600000 + }, + "packet": { + "Framerate": [ + 60, + 60, + 60, + 60, + 60, + 60 + ], + "Sent": 600000, + "Bandwidth": [ + 2.3671154975891115, + 1.9379971027374268, + 2.4939332008361818, + 3.36436128616333, + 3.36436128616333, + 4.041351795196533 + ], + "Recieve": 600000 + }, + "bytenet": { + "Framerate": [ + 60, + 60, + 59, + 58, + 58, + 57 + ], + "Sent": 584000, + "Bandwidth": [ + 2.3301315307617189, + 1.924256443977356, + 2.5273444652557375, + 3.736948062633646, + 3.736948062633646, + 5.375306606292725 + ], + "Recieve": 584000 + }, + "satset": { + "Framerate": [ + 60, + 61, + 60, + 59, + 59, + 58 + ], + "Sent": 599000, + "Bandwidth": [ + 2.654801368713379, + 2.5510362043219096, + 2.760633640601987, + 3.373629570007324, + 3.373629570007324, + 3.4782689193199425 + ], + "Recieve": 83288 + } + } +} \ No newline at end of file From 1ab14fcee989c7da8fb4aef1f55ca7646f8bf8c6 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 01:19:49 +0700 Subject: [PATCH 4/7] docs: add Suphi-Packet to the list of benchmarked libraries --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index dea9399..5a60fb8 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ Satset is designed for high-throughput scenarios. We maintain a [benchmark suite - **[ByteNet](https://github.com/ffrostfall/ByteNet)**: A buffer-based serialization library. - **[BridgeNet2](https://github.com/ffrostfall/BridgeNet2)**: A high-level batching library. - **[Warp](https://github.com/imezx/Warp)**: A rapidly-fast networking library. +- **[Packet](https://devforum.roblox.com/t/packet-networking-library/3573907/414)**: A binary-heavy packet library with built-in rate limiting. Detailed methodology and raw data can be found in the [Benchmarks Report](benchmark/Benchmarks.md). From dab77632672552bc07d691dc8b0ae0e643ca4489 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 06:39:07 +0700 Subject: [PATCH 5/7] docs: use relative link for LICENSE --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5a60fb8..f66b2e2 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,6 @@ end) # License -Satset is distributed under the terms of the [MIT License](https://github.com/protheeuz/satset/blob/main/LICENSE). +Satset is distributed under the terms of the [MIT License](LICENSE). When Satset is integrated into external projects, we ask that you honor the license agreement and include Satset attribution into the user-facing product documentation. From cdc709d54531c28d8e6f01cce02cbadd8826e950 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 09:35:03 +0700 Subject: [PATCH 6/7] docs: add studioBypass to guard configuration example --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f66b2e2..fca43e0 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,8 @@ local Satset = require(path.to.Satset) Satset.start({ guard = { maxTokens = 60, - refillRate = 30 + refillRate = 30, + studioBypass = true -- Enabled by default } }) ``` From 743de803f0c63574cb8891c2543cdcc709ef4f63 Mon Sep 17 00:00:00 2001 From: "0.7%" Date: Fri, 1 May 2026 18:59:34 +0700 Subject: [PATCH 7/7] perf: implement header stripping and serialization fast-paths --- src/Core/Batcher.luau | 394 ++++++++++++++------------ src/Core/Guard.luau | 22 +- src/Networking/Packet.luau | 32 ++- src/Serialization/SchemaCompiler.luau | 144 ++++++++++ src/Serialization/Serializer.luau | 19 +- src/Types/init.luau | 307 ++++++++++++++++++-- 6 files changed, 703 insertions(+), 215 deletions(-) diff --git a/src/Core/Batcher.luau b/src/Core/Batcher.luau index 727ec2f..9f6f424 100644 --- a/src/Core/Batcher.luau +++ b/src/Core/Batcher.luau @@ -1,3 +1,5 @@ +--!native +--!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details -- Batcher — handles per-frame packet batching and unreliable transport hardening. @@ -11,184 +13,155 @@ local IS_SERVER = RunService:IsServer() -- UnreliableRemoteEvent has a limit around 1000 bytes. -- We cap at 900 to stay safe. -local UNRELIABLE_MTU = 900 +local RELIABLE_SPLIT_THRESHOLD = 45000 +local UNRELIABLE_SPLIT_THRESHOLD = 900 +local INITIAL_WORKING_SIZE = 65536 --- [ Types ] +type Stream = { + b: buffer, + cursor: number, + count: number, +} -type QueueEntry = { id: number, payload: buffer } +local serverReliableStream: Stream = { b = buffer.create(INITIAL_WORKING_SIZE), cursor = 2, count = 0 } +local serverUnreliableStream: Stream = { b = buffer.create(INITIAL_WORKING_SIZE), cursor = 4, count = 0 } +local serverReliableBatches: { buffer } = {} +local serverUnreliableBatches: { buffer } = {} --- [ State ] +local clientReliableStreams: { [Player]: Stream } = {} +local clientUnreliableStreams: { [Player]: Stream } = {} +local clientReliableBatches: { [Player]: { buffer } } = {} +local clientUnreliableBatches: { [Player]: { buffer } } = {} --- We use separate queues for reliable and unreliable to respect transport semantics. -local serverReliable: { [Player]: { QueueEntry } } = {} -local serverUnreliable: { [Player]: { QueueEntry } } = {} -local broadcastReliable: { QueueEntry } = {} +local broadcastReliableStream: Stream = { b = buffer.create(INITIAL_WORKING_SIZE), cursor = 2, count = 0 } +local broadcastReliableBatches: { buffer } = {} -local clientReliable: { QueueEntry } = {} -local clientUnreliable: { QueueEntry } = {} - --- Monotonic sequence counters to prevent out-of-order execution on unreliable channels. local serverSeqCounters: { [Player]: number } = {} local clientSeqCounter: number = 0 --- Tracking the last received sequence to discard stale packets. local lastReceivedSeq: { [Player]: number } = {} local lastServerSeq: number = 0 +local packetFixedSizes: { [number]: number } = {} + local Batcher = {} --- [ Private Logic ] +function Batcher.registerFixedSize(id: number, size: number) + packetFixedSizes[id] = size +end ---[[ - Encodes a list of packets into a single reliable batch buffer. - - Wire format: - [u8 packetCount] - ...[u8 id][u16 size][payload] -]] -local function encodeBatch(queue: { QueueEntry }): buffer? - local count = #queue - if count == 0 then - return nil +local function commitStream(stream: Stream, reliable: boolean, seqCounter: number?): (buffer?, number?) + if stream.count == 0 then + return nil, seqCounter end - local totalSize = 1 - for _, entry in queue do - totalSize += 1 + 2 + buffer.len(entry.payload) + local nextSeq = seqCounter + if reliable then + buffer.writeu16(stream.b, 0, stream.count) + else + local val = ((seqCounter or 0) + 1) % 65536 + buffer.writeu16(stream.b, 0, val) + buffer.writeu16(stream.b, 2, stream.count) + nextSeq = val end - local b = buffer.create(totalSize) - local cursor = 0 + -- Always create an exact-sized buffer for sending. Roblox RemoteEvent + -- transmits the entire buffer object, so we must right-size it. + local exactBuffer = buffer.create(stream.cursor) + buffer.copy(exactBuffer, 0, stream.b, 0, stream.cursor) - buffer.writeu8(b, cursor, count) - cursor += 1 + -- Reset stream for next frame. Reuse the existing working buffer + -- to avoid re-allocation when the load is stable across frames. + stream.count = 0 + stream.cursor = reliable and 2 or 4 - for _, entry in queue do - local payloadLen = buffer.len(entry.payload) - buffer.writeu8(b, cursor, entry.id) - cursor += 1 - buffer.writeu16(b, cursor, payloadLen) - cursor += 2 - buffer.copy(b, cursor, entry.payload, 0, payloadLen) - cursor += payloadLen - end - - return b + return exactBuffer, nextSeq end ---[[ - Encodes packets into one or more unreliable batches, ensuring MTU safety. - Each batch gets its own sequence number. - - Wire format: - [u16 sequenceNumber][u8 packetCount] - ...[u8 id][u16 size][payload] -]] -local function encodeUnreliableBatches(queue: { QueueEntry }, seqCounter: number): ({ buffer }, number) - if #queue == 0 then - return {}, seqCounter - end - - local batches: { buffer } = {} - local currentEntries: { QueueEntry } = {} - local currentSize = 3 - - for _, entry in queue do - local entrySize = 1 + 2 + buffer.len(entry.payload) - - -- If this packet would push us over the limit, we flush the current sub-batch. - if currentSize + entrySize > UNRELIABLE_MTU and #currentEntries > 0 then - seqCounter = (seqCounter + 1) % 65536 - local b = buffer.create(currentSize) - local cursor = 0 - - buffer.writeu16(b, cursor, seqCounter) - cursor += 2 - buffer.writeu8(b, cursor, #currentEntries) - cursor += 1 - - for _, e in currentEntries do - local pLen = buffer.len(e.payload) - buffer.writeu8(b, cursor, e.id) - cursor += 1 - buffer.writeu16(b, cursor, pLen) - cursor += 2 - buffer.copy(b, cursor, e.payload, 0, pLen) - cursor += pLen - end - - table.insert(batches, b) - table.clear(currentEntries) - currentSize = 3 +local function allocateInStream( + stream: Stream, + packetId: number, + size: number, + splitThreshold: number, + reliable: boolean, + seqCounter: number?, + queueTo: { buffer } +): (buffer, number, number?) + local isFixed = packetFixedSizes[packetId] ~= nil + local entrySize = 1 + (isFixed and 0 or 2) + size + + if stream.cursor + entrySize > splitThreshold and stream.count > 0 then + local exactBuffer, newSeq = commitStream(stream, reliable, seqCounter) + if exactBuffer then + table.insert(queueTo, exactBuffer) + end + if newSeq then + seqCounter = newSeq end - - table.insert(currentEntries, entry) - currentSize += entrySize end - -- Flush any remaining packets. - if #currentEntries > 0 then - seqCounter = (seqCounter + 1) % 65536 - local b = buffer.create(currentSize) - local cursor = 0 + if stream.cursor + entrySize > buffer.len(stream.b) then + local newB = buffer.create(math.max(buffer.len(stream.b) * 2, stream.cursor + entrySize)) + buffer.copy(newB, 0, stream.b, 0, stream.cursor) + stream.b = newB + end - buffer.writeu16(b, cursor, seqCounter) - cursor += 2 - buffer.writeu8(b, cursor, #currentEntries) - cursor += 1 + local offset = stream.cursor + buffer.writeu8(stream.b, offset, packetId) - for _, e in currentEntries do - local pLen = buffer.len(e.payload) - buffer.writeu8(b, cursor, e.id) - cursor += 1 - buffer.writeu16(b, cursor, pLen) - cursor += 2 - buffer.copy(b, cursor, e.payload, 0, pLen) - cursor += pLen - end - - table.insert(batches, b) + if not isFixed then + buffer.writeu16(stream.b, offset + 1, size) + offset += 2 end - return batches, seqCounter + stream.cursor += entrySize + stream.count += 1 + + return stream.b, offset + 1, seqCounter end +type QueueEntry = { id: number, b: buffer, offset: number, len: number } -- [ Internal API ] --[[ - Splits a reliable batch back into individual packets. + Splits a reliable batch back into individual packets without allocating new buffers. ]] function Batcher.decodeBatch(b: buffer): { QueueEntry } local results = {} local cursor = 0 local bufLen = buffer.len(b) - if bufLen < 1 then + if bufLen < 2 then return results end - local count = buffer.readu8(b, cursor) - cursor += 1 + local count = buffer.readu16(b, cursor) + cursor += 2 + -- print(`[Satset Debug] Batch size: {count}`) for _ = 1, count do - if cursor + 3 > bufLen then + if cursor + 1 > bufLen then break end local id = buffer.readu8(b, cursor) cursor += 1 - local payloadLen = buffer.readu16(b, cursor) - cursor += 2 + + local payloadLen = packetFixedSizes[id] + if not payloadLen then + if cursor + 2 > bufLen then + break + end + payloadLen = buffer.readu16(b, cursor) + cursor += 2 + end if cursor + payloadLen > bufLen then break end - local payload = buffer.create(payloadLen) - buffer.copy(payload, 0, b, cursor, payloadLen) + table.insert(results, { id = id, b = b, offset = cursor, len = payloadLen }) cursor += payloadLen - - table.insert(results, { id = id, payload = payload }) end return results @@ -200,7 +173,7 @@ end ]] function Batcher.decodeUnreliableBatch(b: buffer, sender: Player?): { QueueEntry }? local bufLen = buffer.len(b) - if bufLen < 3 then + if bufLen < 4 then return nil end @@ -228,28 +201,32 @@ function Batcher.decodeUnreliableBatch(b: buffer, sender: Player?): { QueueEntry local results = {} local cursor = 2 - local count = buffer.readu8(b, cursor) - cursor += 1 + local count = buffer.readu16(b, cursor) + cursor += 2 for _ = 1, count do - if cursor + 3 > bufLen then + if cursor + 1 > bufLen then break end local id = buffer.readu8(b, cursor) cursor += 1 - local payloadLen = buffer.readu16(b, cursor) - cursor += 2 + + local payloadLen = packetFixedSizes[id] + if not payloadLen then + if cursor + 2 > bufLen then + break + end + payloadLen = buffer.readu16(b, cursor) + cursor += 2 + end if cursor + payloadLen > bufLen then break end - local payload = buffer.create(payloadLen) - buffer.copy(payload, 0, b, cursor, payloadLen) + table.insert(results, { id = id, b = b, offset = cursor, len = payloadLen }) cursor += payloadLen - - table.insert(results, { id = id, payload = payload }) end return results @@ -257,29 +234,78 @@ end -- [ Enqueue API ] -function Batcher.enqueueForPlayer(player: Player, packetId: number, payload: buffer, reliable: boolean) +function Batcher.allocateForPlayer(player: Player, packetId: number, size: number, reliable: boolean): (buffer, number) if reliable then - if not serverReliable[player] then - serverReliable[player] = {} + local stream = clientReliableStreams[player] + if not stream then + stream = { b = buffer.create(INITIAL_WORKING_SIZE), cursor = 2, count = 0 } + clientReliableStreams[player] = stream + end + local batches = clientReliableBatches[player] + if not batches then + batches = {} + clientReliableBatches[player] = batches end - table.insert(serverReliable[player], { id = packetId, payload = payload }) + + local b, offset = allocateInStream(stream, packetId, size, RELIABLE_SPLIT_THRESHOLD, true, nil, batches) + return b, offset else - if not serverUnreliable[player] then - serverUnreliable[player] = {} + local stream = clientUnreliableStreams[player] + if not stream then + stream = { b = buffer.create(INITIAL_WORKING_SIZE), cursor = 4, count = 0 } + clientUnreliableStreams[player] = stream + end + local batches = clientUnreliableBatches[player] + if not batches then + batches = {} + clientUnreliableBatches[player] = batches end - table.insert(serverUnreliable[player], { id = packetId, payload = payload }) + + local seq = serverSeqCounters[player] or 0 + local b, offset, newSeq = + allocateInStream(stream, packetId, size, UNRELIABLE_SPLIT_THRESHOLD, false, seq, batches) + serverSeqCounters[player] = newSeq :: number + return b, offset end end -function Batcher.enqueueForAllPlayers(packetId: number, payload: buffer) - table.insert(broadcastReliable, { id = packetId, payload = payload }) +function Batcher.allocateForAllPlayers(packetId: number, size: number): (buffer, number) + local b, offset = allocateInStream( + broadcastReliableStream, + packetId, + size, + RELIABLE_SPLIT_THRESHOLD, + true, + nil, + broadcastReliableBatches + ) + return b, offset end -function Batcher.enqueueForServer(packetId: number, payload: buffer, reliable: boolean) +function Batcher.allocateForServer(packetId: number, size: number, reliable: boolean): (buffer, number) if reliable then - table.insert(clientReliable, { id = packetId, payload = payload }) + local b, offset = allocateInStream( + serverReliableStream, + packetId, + size, + RELIABLE_SPLIT_THRESHOLD, + true, + nil, + serverReliableBatches + ) + return b, offset else - table.insert(clientUnreliable, { id = packetId, payload = payload }) + local b, offset, newSeq = allocateInStream( + serverUnreliableStream, + packetId, + size, + UNRELIABLE_SPLIT_THRESHOLD, + false, + clientSeqCounter, + serverUnreliableBatches + ) + clientSeqCounter = newSeq :: number + return b, offset end end @@ -290,55 +316,71 @@ local function flush() local reliableRemote = Bridge.getReliable() local unreliableRemote = Bridge.getUnreliable() - for player, queue in serverReliable do - if #queue > 0 then - local batch = encodeBatch(queue) - if batch then + for player, stream in clientReliableStreams do + local exact, _ = commitStream(stream, true) + local batches = clientReliableBatches[player] + if batches then + if exact then + table.insert(batches, exact) + end + for _, batch in batches do reliableRemote:FireClient(player, batch) end - table.clear(queue) + table.clear(batches) + elseif exact then + reliableRemote:FireClient(player, exact) end end - for player, queue in serverUnreliable do - if #queue > 0 then - local seq = serverSeqCounters[player] or 0 - local batches, newSeq = encodeUnreliableBatches(queue, seq) - serverSeqCounters[player] = newSeq + for player, stream in clientUnreliableStreams do + local seq = serverSeqCounters[player] or 0 + local exact, newSeq = commitStream(stream, false, seq) + serverSeqCounters[player] = newSeq :: number + local batches = clientUnreliableBatches[player] + if batches then + if exact then + table.insert(batches, exact) + end for _, batch in batches do unreliableRemote:FireClient(player, batch) end - - table.clear(queue) + table.clear(batches) + elseif exact then + unreliableRemote:FireClient(player, exact) end end - if #broadcastReliable > 0 then - local batch = encodeBatch(broadcastReliable) - if batch then - reliableRemote:FireAllClients(batch) - end - table.clear(broadcastReliable) + local exact, _ = commitStream(broadcastReliableStream, true) + if exact then + table.insert(broadcastReliableBatches, exact) + end + for _, batch in broadcastReliableBatches do + reliableRemote:FireAllClients(batch) end + table.clear(broadcastReliableBatches) else - if #clientReliable > 0 then - local batch = encodeBatch(clientReliable) - if batch then + local exactR, _ = commitStream(serverReliableStream, true) + if exactR then + table.insert(serverReliableBatches, exactR) + end + if #serverReliableBatches > 0 then + for _, batch in serverReliableBatches do Bridge.getReliable():FireServer(batch) end - table.clear(clientReliable) + table.clear(serverReliableBatches) end - if #clientUnreliable > 0 then - local batches, newSeq = encodeUnreliableBatches(clientUnreliable, clientSeqCounter) - clientSeqCounter = newSeq - - for _, batch in batches do + local exactU, newSeq = commitStream(serverUnreliableStream, false, clientSeqCounter) + clientSeqCounter = newSeq :: number + if exactU then + table.insert(serverUnreliableBatches, exactU) + end + if #serverUnreliableBatches > 0 then + for _, batch in serverUnreliableBatches do Bridge.getUnreliable():FireServer(batch) end - - table.clear(clientUnreliable) + table.clear(serverUnreliableBatches) end end end @@ -354,8 +396,10 @@ end Purges a player's state when they disconnect. ]] function Batcher.removePlayer(player: Player) - serverReliable[player] = nil - serverUnreliable[player] = nil + clientReliableStreams[player] = nil + clientUnreliableStreams[player] = nil + clientReliableBatches[player] = nil + clientUnreliableBatches[player] = nil serverSeqCounters[player] = nil lastReceivedSeq[player] = nil end diff --git a/src/Core/Guard.luau b/src/Core/Guard.luau index 0604f0b..a2d78df 100644 --- a/src/Core/Guard.luau +++ b/src/Core/Guard.luau @@ -1,12 +1,19 @@ -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details -- Guard — server-side token bucket rate limiter for DDoS protection. +local RunService = game:GetService("RunService") + +-- [ Constants ] + +local IS_STUDIO = RunService:IsStudio() + -- [ Types ] export type GuardConfig = { maxTokens: number, -- Max burst capacity (how many packets can be sent at once) refillRate: number, -- Tokens refilled per second onFlood: ((player: Player) -> ())?, -- Optional callback for when a player is flooding + studioBypass: boolean?, -- If true, rate limiting is disabled in Studio (Default: true) } type PlayerBucket = { @@ -28,9 +35,10 @@ export type Guard = { -- [ Private Constants ] local DEFAULT_CONFIG: GuardConfig = { - maxTokens = 60, - refillRate = 30, + maxTokens = 1000, + refillRate = 500, onFlood = nil, + studioBypass = true, } -- [ Public API ] @@ -49,9 +57,10 @@ function Guard.new(config: GuardConfig?): Guard local self = setmetatable({}, Guard) local cfg = config or DEFAULT_CONFIG - self._maxTokens = cfg.maxTokens or DEFAULT_CONFIG.maxTokens - self._refillRate = cfg.refillRate or DEFAULT_CONFIG.refillRate + self._maxTokens = math.max(cfg.maxTokens or DEFAULT_CONFIG.maxTokens, 1) + self._refillRate = math.max(cfg.refillRate or DEFAULT_CONFIG.refillRate, 1) self._onFlood = cfg.onFlood + self._studioBypass = if cfg.studioBypass ~= nil then cfg.studioBypass else DEFAULT_CONFIG.studioBypass self._buckets = {} :: { [Player]: PlayerBucket } return (self :: any) :: Guard @@ -64,6 +73,11 @@ end the player is flooding. ]] function Guard:consume(player: Player): boolean + -- Bypass rate limiting in Studio if enabled. + if IS_STUDIO and self._studioBypass then + return true + end + local bucket: PlayerBucket = self:_getBucket(player) local now = os.clock() local elapsed = now - bucket.lastRefill diff --git a/src/Networking/Packet.luau b/src/Networking/Packet.luau index 616a6f5..0faf436 100644 --- a/src/Networking/Packet.luau +++ b/src/Networking/Packet.luau @@ -66,6 +66,10 @@ function Packet.definePacket(config: PacketConfig): Packet listeners = {}, } + if compiled.fixedSize then + Batcher.registerFixedSize(id, compiled.fixedSize) + end + packetRegistry[id] = def packetsByName[config.name] = def @@ -73,20 +77,23 @@ function Packet.definePacket(config: PacketConfig): Packet function self:fireServer(data: { [string]: any }) assert(not IS_SERVER, "[Satset] fireServer can only be called from the client") - local payload = Serializer.encode(compiled, data) - Batcher.enqueueForServer(id, payload, def.reliable) + local size = compiled.fixedSize or Serializer.calculateSize(compiled, data) + local b, offset = Batcher.allocateForServer(id, size, def.reliable) + Serializer.encodeInto(compiled, data, b, offset) end function self:fireClient(player: Player, data: { [string]: any }) assert(IS_SERVER, "[Satset] fireClient can only be called from the server") - local payload = Serializer.encode(compiled, data) - Batcher.enqueueForPlayer(player, id, payload, def.reliable) + local size = compiled.fixedSize or Serializer.calculateSize(compiled, data) + local b, offset = Batcher.allocateForPlayer(player, id, size, def.reliable) + Serializer.encodeInto(compiled, data, b, offset) end function self:fireAllClients(data: { [string]: any }) assert(IS_SERVER, "[Satset] fireAllClients can only be called from the server") - local payload = Serializer.encode(compiled, data) - Batcher.enqueueForAllPlayers(id, payload) + local size = compiled.fixedSize or Serializer.calculateSize(compiled, data) + local b, offset = Batcher.allocateForAllPlayers(id, size) + Serializer.encodeInto(compiled, data, b, offset) end function self:listen(callback: (data: { [string]: any }, sender: Player?) -> ()) @@ -101,14 +108,17 @@ end --[[ Decodes and dispatches a single packet to its registered listeners. ]] -function Packet._dispatchSingle(packetId: number, payload: buffer, sender: Player?) +function Packet._dispatchSingle(packetId: number, b: buffer, offset: number, sender: Player?) local def = packetRegistry[packetId] if not def then return end - local data = Serializer.decode(def.compiled, payload) - if not data then + -- pcall shields against buffer OOB from crafted payloads. + -- Cost: ~0ns per packet (closure allocation eliminated by direct arg passing). + local ok, result = pcall(Serializer.decodeFrom :: any, def.compiled, b, offset) + local data: { [string]: any }? = if ok then result :: any else nil + if not ok or not data then return end @@ -126,7 +136,7 @@ function Packet._dispatch(batchPayload: buffer, sender: Player?) local entries = Batcher.decodeBatch(batchPayload) for _, entry in entries do - Packet._dispatchSingle(entry.id, entry.payload, sender) + Packet._dispatchSingle(entry.id, entry.b, entry.offset, sender) end end @@ -140,7 +150,7 @@ function Packet._dispatchUnreliable(batchPayload: buffer, sender: Player?) end for _, entry in entries do - Packet._dispatchSingle(entry.id, entry.payload, sender) + Packet._dispatchSingle(entry.id, entry.b, entry.offset, sender) end end diff --git a/src/Serialization/SchemaCompiler.luau b/src/Serialization/SchemaCompiler.luau index afe627f..28b0498 100644 --- a/src/Serialization/SchemaCompiler.luau +++ b/src/Serialization/SchemaCompiler.luau @@ -1,3 +1,4 @@ +--!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details -- SchemaCompiler — transforms a schema dictionary into a deterministic ordered structure. @@ -17,10 +18,139 @@ export type CompiledSchema = { fixedSize: number?, -- nil if any field is variable-sized fieldCount: number, fieldsByName: { [string]: FieldDef }, -- O(1) lookup for runtime access + encoder: ((b: buffer, offset: number, data: { [string]: any }) -> ())?, -- compiled fast-path encoder + decoder: ((b: buffer, offset: number) -> { [string]: any })?, -- compiled fast-path decoder } local SchemaCompiler = {} +-- [ Private ] + +--[[ + Generates a specialized encoder closure for fixed-size schemas. + + Instead of looping over fields at runtime, we capture each field's + name, write function, and byte offset as closure upvalues. Luau + optimizes immutable upvalue captures as by-value, making this + equivalent to hand-written code. +]] +local function buildFixedEncoder(fields: { FieldDef }): (b: buffer, offset: number, data: { [string]: any }) -> () + local count = #fields + + -- 1 field — most common for simple packets (SingleValue, Booleans, Vectors, etc.) + if count == 1 then + local f1 = fields[1] + local name1, write1, off1 = f1.name, f1.type.write, f1.offset + return function(b: buffer, offset: number, data: { [string]: any }) + write1(b, offset + off1, data[name1]) + end + elseif count == 2 then + local f1, f2 = fields[1], fields[2] + local name1, write1, off1 = f1.name, f1.type.write, f1.offset + local name2, write2, off2 = f2.name, f2.type.write, f2.offset + return function(b: buffer, offset: number, data: { [string]: any }) + write1(b, offset + off1, data[name1]) + write2(b, offset + off2, data[name2]) + end + elseif count == 3 then + local f1, f2, f3 = fields[1], fields[2], fields[3] + local name1, write1, off1 = f1.name, f1.type.write, f1.offset + local name2, write2, off2 = f2.name, f2.type.write, f2.offset + local name3, write3, off3 = f3.name, f3.type.write, f3.offset + return function(b: buffer, offset: number, data: { [string]: any }) + write1(b, offset + off1, data[name1]) + write2(b, offset + off2, data[name2]) + write3(b, offset + off3, data[name3]) + end + elseif count == 4 then + local f1, f2, f3, f4 = fields[1], fields[2], fields[3], fields[4] + local name1, write1, off1 = f1.name, f1.type.write, f1.offset + local name2, write2, off2 = f2.name, f2.type.write, f2.offset + local name3, write3, off3 = f3.name, f3.type.write, f3.offset + local name4, write4, off4 = f4.name, f4.type.write, f4.offset + return function(b: buffer, offset: number, data: { [string]: any }) + write1(b, offset + off1, data[name1]) + write2(b, offset + off2, data[name2]) + write3(b, offset + off3, data[name3]) + write4(b, offset + off4, data[name4]) + end + end + + -- 5+ fields: use a pre-built array of captured closures with offsets. + -- Still faster than the generic path because we avoid getSize() calls + -- and the offset is pre-computed. + local names = table.create(count) + local writes = table.create(count) + local offsets = table.create(count) + for i, field in fields do + names[i] = field.name + writes[i] = field.type.write + offsets[i] = field.offset + end + + return function(b: buffer, offset: number, data: { [string]: any }) + for i = 1, count do + writes[i](b, offset + offsets[i], data[names[i]]) + end + end +end + +--[[ + Generates a specialized decoder closure for fixed-size schemas. +]] +local function buildFixedDecoder(fields: { FieldDef }): (b: buffer, offset: number) -> { [string]: any } + local count = #fields + + -- 1 field + if count == 1 then + local f1 = fields[1] + local name1, read1, off1 = f1.name, f1.type.read, f1.offset + return function(b: buffer, offset: number): { [string]: any } + return { [name1] = read1(b, offset + off1) } + end + elseif count == 2 then + local f1, f2 = fields[1], fields[2] + local name1, read1, off1 = f1.name, f1.type.read, f1.offset + local name2, read2, off2 = f2.name, f2.type.read, f2.offset + return function(b: buffer, offset: number): { [string]: any } + return { + [name1] = read1(b, offset + off1), + [name2] = read2(b, offset + off2), + } + end + elseif count == 3 then + local f1, f2, f3 = fields[1], fields[2], fields[3] + local name1, read1, off1 = f1.name, f1.type.read, f1.offset + local name2, read2, off2 = f2.name, f2.type.read, f2.offset + local name3, read3, off3 = f3.name, f3.type.read, f3.offset + return function(b: buffer, offset: number): { [string]: any } + return { + [name1] = read1(b, offset + off1), + [name2] = read2(b, offset + off2), + [name3] = read3(b, offset + off3), + } + end + end + + -- 4+ fields: pre-built arrays + local names = table.create(count) + local reads = table.create(count) + local offsets = table.create(count) + for i, field in fields do + names[i] = field.name + reads[i] = field.type.read + offsets[i] = field.offset + end + + return function(b: buffer, offset: number): { [string]: any } + local data = {} + for i = 1, count do + data[names[i]] = reads[i](b, offset + offsets[i]) + end + return data + end +end + -- [ Public API ] --[[ @@ -29,6 +159,9 @@ local SchemaCompiler = {} Luau does not guarantee table iteration order, so we sort the fields alphabetically. This ensures that the server and client always agree on where data is located in the buffer. + + For fixed-size schemas, we also generate specialized encoder/decoder + closures that eliminate the per-fire field loop overhead. ]] function SchemaCompiler.compile(schema: { [string]: Types.Type }): CompiledSchema local fields: { FieldDef } = {} @@ -67,11 +200,22 @@ function SchemaCompiler.compile(schema: { [string]: Types.Type }): Compiled fieldsByName[field.name] = field end + -- Generate compiled encoder/decoder for fixed-size schemas. + local encoder: ((b: buffer, offset: number, data: { [string]: any }) -> ())? = nil + local decoder: ((b: buffer, offset: number) -> { [string]: any })? = nil + + if isFixedSize then + encoder = buildFixedEncoder(fields) + decoder = buildFixedDecoder(fields) + end + return { fields = fields, fixedSize = if isFixedSize then totalFixed else nil, fieldCount = #fields, fieldsByName = fieldsByName, + encoder = encoder, + decoder = decoder, } end diff --git a/src/Serialization/Serializer.luau b/src/Serialization/Serializer.luau index ec5f94b..df213c6 100644 --- a/src/Serialization/Serializer.luau +++ b/src/Serialization/Serializer.luau @@ -1,3 +1,5 @@ +--!native +--!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details -- Serializer — handles the actual encoding and decoding of data into binary buffers. @@ -49,6 +51,12 @@ function Serializer.encodeInto( b: buffer, offset: number ): number + -- Compiled encoder fast-path: bypasses the field loop entirely. + if compiled.encoder then + compiled.encoder(b, offset, data) + return compiled.fixedSize :: number + end + local cursor = offset for _, field in compiled.fields do local value = data[field.name] @@ -70,14 +78,21 @@ function Serializer.decodeFrom( offset: number ): ({ [string]: any }?, number) local bufLen = buffer.len(b) - local data = {} - local cursor = offset -- Bulk bounds check for fixed-size schemas. if compiled.fixedSize and (offset + compiled.fixedSize) > bufLen then return nil, 0 end + -- Compiled decoder fast-path: single bounds check, then direct read. + if compiled.decoder then + local data = compiled.decoder(b, offset) + return data, compiled.fixedSize :: number + end + + local data = {} + local cursor = offset + for _, field in compiled.fields do -- Pre-read bounds check. if not Sanitizer.checkBounds(b, cursor, field.type.size) then diff --git a/src/Types/init.luau b/src/Types/init.luau index 0d3211c..c463248 100644 --- a/src/Types/init.luau +++ b/src/Types/init.luau @@ -1,3 +1,5 @@ +--!native +--!optimize 2 -- This file is part of the Satset networking library and is licensed under MIT License; see LICENSE.txt for details -- Types — defines all primitive and composite networking types. @@ -26,7 +28,9 @@ local round = math.round local sqrt = math.sqrt local abs = math.abs local max = math.max +local min = math.min local huge = math.huge +local floor = math.floor -- [ Types ] @@ -51,10 +55,7 @@ end -- Clamps NaN and ±Infinity to 0. Prevents malicious clients from -- injecting poisoned floats that propagate through server arithmetic. local function sanitizeFloat(v: number): number - if v ~= v or v == huge or v == -huge then - return 0 - end - return v + return if v == v and v ~= huge and v ~= -huge then v else 0 end -- [ Primitives ] @@ -201,16 +202,20 @@ Types.string16 = { Types.Vector3 = { read = function(b: buffer, cursor: number) + local x = readf32(b, cursor) + local y = readf32(b, cursor + 4) + local z = readf32(b, cursor + 8) return Vector3.new( - sanitizeFloat(readf32(b, cursor)), - sanitizeFloat(readf32(b, cursor + 4)), - sanitizeFloat(readf32(b, cursor + 8)) + if x == x and x ~= huge and x ~= -huge then x else 0, + if y == y and y ~= huge and y ~= -huge then y else 0, + if z == z and z ~= huge and z ~= -huge then z else 0 ) end, write = function(b: buffer, cursor: number, value: Vector3) - writef32(b, cursor, sanitizeFloat(value.X)) - writef32(b, cursor + 4, sanitizeFloat(value.Y)) - writef32(b, cursor + 8, sanitizeFloat(value.Z)) + local x, y, z = value.X, value.Y, value.Z + writef32(b, cursor, if x == x and x ~= huge and x ~= -huge then x else 0) + writef32(b, cursor + 4, if y == y and y ~= huge and y ~= -huge then y else 0) + writef32(b, cursor + 8, if z == z and z ~= huge and z ~= -huge then z else 0) end, size = 12, getSize = fixedSize(12), @@ -462,15 +467,258 @@ function Types.optional(innerType: Type): Type end function Types.array(elementType: Type): Type<{ any }> + -- [ Fast-Path Unrolling for Common Primitives ] + -- Eliminates thousands of per-element function call overheads by inlining buffer operations + if elementType == Types.Vector3 then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + -- Cap allocation: exploiter can't claim 65535 elements in a tiny buffer. + local maxPossible = floor((bufLen - cursor - 2) / 12) + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + local x = readf32(b, pos) + local y = readf32(b, pos + 4) + local z = readf32(b, pos + 8) + arr[i] = Vector3.new( + if x == x and x ~= huge and x ~= -huge then x else 0, + if y == y and y ~= huge and y ~= -huge then y else 0, + if z == z and z ~= huge and z ~= -huge then z else 0 + ) + pos += 12 + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + local val = value[i] + local x, y, z = val.X, val.Y, val.Z + writef32(b, pos, if x == x and x ~= huge and x ~= -huge then x else 0) + writef32(b, pos + 4, if y == y and y ~= huge and y ~= -huge then y else 0) + writef32(b, pos + 8, if z == z and z ~= huge and z ~= -huge then z else 0) + pos += 12 + end + end, + size = 2, + getSize = function(value: { any }) + return 2 + (#value * 12) + end, + isFixed = false, + } + elseif elementType == Types.bool then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = bufLen - cursor - 2 + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + arr[i] = readu8(b, pos) ~= 0 + pos += 1 + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + writeu8(b, pos, value[i] and 1 or 0) + pos += 1 + end + end, + size = 2, + getSize = function(value: { any }) + return 2 + #value + end, + isFixed = false, + } + elseif elementType == Types.string8 then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = bufLen - cursor - 2 + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + local strLen = readu8(b, pos) + arr[i] = readstring(b, pos + 1, strLen) + pos += 1 + strLen + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + local str = value[i] + local strLen = #str + writeu8(b, pos, strLen) + writestring(b, pos + 1, str, strLen) + pos += 1 + strLen + end + end, + size = 2, + getSize = function(value: { any }) + local total = 2 + for i = 1, #value do + total += 1 + #value[i] + end + return total + end, + isFixed = false, + } + elseif elementType == Types.string16 then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = bufLen - cursor - 2 + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + local strLen = readu16(b, pos) + arr[i] = readstring(b, pos + 2, strLen) + pos += 2 + strLen + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + local str = value[i] + local strLen = #str + writeu16(b, pos, strLen) + writestring(b, pos + 2, str, strLen) + pos += 2 + strLen + end + end, + size = 2, + getSize = function(value: { any }) + local total = 2 + for i = 1, #value do + total += 2 + #value[i] + end + return total + end, + isFixed = false, + } + elseif elementType == Types.u8 then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = bufLen - cursor - 2 + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + arr[i] = readu8(b, pos) + pos += 1 + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + writeu8(b, pos, value[i]) + pos += 1 + end + end, + size = 2, + getSize = function(value: { any }) + return 2 + #value + end, + isFixed = false, + } + elseif elementType == Types.f32 then + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = floor((bufLen - cursor - 2) / 4) + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + arr[i] = sanitizeFloat(readf32(b, pos)) + pos += 4 + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + writef32(b, pos, sanitizeFloat(value[i])) + pos += 4 + end + end, + size = 2, + getSize = function(value: { any }) + return 2 + (#value * 4) + end, + isFixed = false, + } + end + + -- [ Standard Path: Fixed-Size Element Types ] + -- Pre-capture step size to avoid per-element getSize() calls and conditional branches. + if elementType.isFixed then + local step = elementType.size + local elemRead = elementType.read + local elemWrite = elementType.write + return { + read = function(b: buffer, cursor: number) + local bufLen = buffer.len(b) + local len = readu16(b, cursor) + local maxPossible = floor((bufLen - cursor - 2) / step) + len = min(len, max(maxPossible, 0)) + local arr, pos = table.create(len), cursor + 2 + for i = 1, len do + arr[i] = elemRead(b, pos) + pos += step + end + return arr + end, + write = function(b: buffer, cursor: number, value: { any }) + local len = #value + writeu16(b, cursor, len) + local pos = cursor + 2 + for i = 1, len do + elemWrite(b, pos, value[i]) + pos += step + end + end, + size = 2, + getSize = function(value: { any }) + return 2 + (#value * step) + end, + isFixed = false, + } + end + + -- [ Standard Path: Variable-Size Element Types ] return { read = function(b: buffer, cursor: number) local bufLen = buffer.len(b) local len = readu16(b, cursor) + local maxPossible = bufLen - cursor - 2 + len = min(len, max(maxPossible, 0)) local arr, pos = table.create(len), cursor + 2 + for i = 1, len do - if pos + elementType.size > bufLen then - break - end local val = elementType.read(b, pos) arr[i] = val pos += elementType.getSize(val) @@ -481,6 +729,7 @@ function Types.array(elementType: Type): Type<{ any }> local len = #value writeu16(b, cursor, len) local pos = cursor + 2 + for i = 1, len do local val = value[i] elementType.write(b, pos, val) @@ -504,18 +753,19 @@ function Types.map(keyType: Type, valueType: Type): Type<{ [any]: any read = function(b: buffer, cursor: number) local bufLen = buffer.len(b) local count = readu16(b, cursor) + -- Cap iteration count: can't have more entries than remaining bytes. + local maxPossible = bufLen - cursor - 2 + count = min(count, max(maxPossible, 0)) local result, pos = {}, cursor + 2 + local kFixed, vFixed = keyType.isFixed, valueType.isFixed + local kStep, vStep = keyType.size, valueType.size + for _ = 1, count do - if pos + keyType.size > bufLen then - break - end local key = keyType.read(b, pos) - pos += keyType.getSize(key) - if pos + valueType.size > bufLen then - break - end + pos += kFixed and kStep or keyType.getSize(key) + local val = valueType.read(b, pos) - pos += valueType.getSize(val) + pos += vFixed and vStep or valueType.getSize(val) result[key] = val end return result @@ -532,17 +782,28 @@ function Types.map(keyType: Type, valueType: Type): Type<{ [any]: any writeu16(b, cursor, n) local pos = cursor + 2 + local kFixed, vFixed = keyType.isFixed, valueType.isFixed + local kStep, vStep = keyType.size, valueType.size + for i = 1, n do local k = keys[i] local v = value[k] keyType.write(b, pos, k) - pos += keyType.getSize(k) + pos += kFixed and kStep or keyType.getSize(k) valueType.write(b, pos, v) - pos += valueType.getSize(v) + pos += vFixed and vStep or valueType.getSize(v) end end, size = 2, getSize = function(value: { [any]: any }) + if keyType.isFixed and valueType.isFixed then + local count = 0 + for _ in value do + count += 1 + end + return 2 + (count * (keyType.size + valueType.size)) + end + local total = 2 for k, v in value do total += keyType.getSize(k) + valueType.getSize(v)