├── .editorconfig ├── .gitignore ├── CHANGELOG.md ├── ExampleProject ├── .gitignore ├── README.md ├── default.project.json ├── foreman.toml ├── src │ ├── client │ │ └── init.client.lua │ ├── server │ │ └── init.server.lua │ └── shared │ │ └── module.lua ├── wally.project.json └── wally.toml ├── LICENSE ├── README.md ├── aftman.toml ├── default.project.json ├── gameanalytics-sdk ├── GameAnalytics │ ├── Events.lua │ ├── GAErrorSeverity.lua │ ├── GAProgressionStatus.lua │ ├── GAResourceFlowType.lua │ ├── HttpApi │ │ ├── HashLib │ │ │ ├── Base64.lua │ │ │ └── init.lua │ │ └── init.lua │ ├── Logger.lua │ ├── Postie.lua │ ├── State.lua │ ├── Store │ │ ├── DataStoreQueue.lua │ │ └── init.lua │ ├── Threading.lua │ ├── Types.lua │ ├── Utilities.lua │ ├── Validation.lua │ ├── Version.lua │ └── init.lua ├── GameAnalyticsClient.lua └── init.lua ├── selene.toml ├── sourcemap.json └── wally.toml /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*.{lua,json,toml}] 7 | indent_style = tab 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | charset = utf-8 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Roblox place files 2 | *.rbxmx 3 | *.rbxm 4 | *.rbxl 5 | *.rbxlx 6 | 7 | # Other 8 | roblox.toml 9 | 10 | *.DS_Store 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | --------- 4 | 5 | All notable changes to this project will be documented in this file. 6 | 7 | ## [2.2.6] 8 | 9 | - Fixed a bug with teleport data not being validated properly before extrating analytics data 10 | - Added changelog 11 | - Cleaned the README 12 | 13 | 14 | ## [2.2.5] 15 | 16 | - Add "custom_fields" to events 17 | - Added types to the public facing api 18 | - Applied StyLua to codebase 19 | 20 | ## [2.2.4] 21 | 22 | - Removed LocalScript that was added in 2.2.3, causing errors on requiring GameAnalyticsClient module 23 | 24 | ## [2.2.3] 25 | 26 | - Fixed bug where PlayerData would reference BasePlayerData tables instead of making copies 27 | - Added datastore queue 28 | 29 | ## [2.2.2] 30 | 31 | - Fix to wally support 32 | 33 | ## [2.2.1] 34 | 35 | - Fixed bug related to game passes purchases 36 | 37 | ## [2.2.0] 38 | 39 | - Added support for wally (OBS: breaking changes) 40 | 41 | ## [2.1.35] 42 | 43 | - Fixed argument order 44 | 45 | ## [2.1.34] 46 | 47 | - Corrected to use new postie api where referenced 48 | 49 | ## [2.1.33] 50 | 51 | - Postie script updated 52 | 53 | ## [2.1.32] 54 | 55 | - Replace global spawn and wait with task library 56 | 57 | ## [2.1.31] 58 | 59 | - Fixed postie bug after moving the script under gameanalytics script 60 | 61 | ## [2.1.30] 62 | 63 | - Replaced rbxmx generator script with rojo build command (requires min. rojo 6) 64 | 65 | ## [2.1.29] 66 | 67 | - Moved postie script inside GameAnalytics scripts 68 | 69 | ## [2.1.28] 70 | 71 | - Fixed error message in errorhandler 72 | 73 | ## [2.1.27] 74 | 75 | - Country code field always sent with events now (sent as 'null' if country code couldn't be fetched) 76 | 77 | ## [2.1.26] 78 | 79 | - Fixed undefined variable errors 80 | 81 | ## [2.1.25] 82 | 83 | - Fixed potential error in session end event code 84 | 85 | ## [2.1.24] 86 | 87 | - Added error tracking if country code fails to get retrieved 88 | 89 | ## [2.1.23] 90 | 91 | - Fixed ab testing 92 | 93 | ## [2.1.22] 94 | 95 | - Fixed bug with remote configs and ab testing ids not being added to events 96 | 97 | ## [2.1.21] 98 | 99 | - Fixed GetPlayerDataFromCache function 100 | 101 | ## [2.1.20] 102 | 103 | - Fixes to gamepass in business events 104 | 105 | ## [2.1.19] 106 | 107 | - Fixed detection of website gamepasses throttling datastores 108 | 109 | ## [2.1.18] 110 | 111 | - Fixed sesion start and end issues which caused problems with metrics 112 | 113 | ## [2.1.17] 114 | 115 | - Corrected variable name inside GetPlayerDataFromCache function 116 | 117 | ## [2.1.16] 118 | 119 | - Player data cache now accepts both userId of string or number type 120 | 121 | ## [2.1.15] 122 | 123 | - Fixed not clearing session start ts because teleport flag was not cleared 124 | 125 | ## [2.1.14] 126 | 127 | - Fixed logic for error handler 128 | 129 | ## [2.1.13] 130 | 131 | - Added player id to error events sent from error reporting 132 | 133 | ## [2.1.12] 134 | 135 | - Fixed ScriptContext.Error error reporting 136 | 137 | ## [2.1.11] 138 | 139 | - Switched from using LogService to ScriptContext.Error for error reporting 140 | 141 | ## [2.1.10] 142 | 143 | - Fixed setAvailableGamepasses function 144 | 145 | ## [2.1.9] 146 | 147 | - Correct install instructions and fixed GameAnalyticsServerInit script 148 | 149 | ## [2.1.8] 150 | 151 | - Correct business event for 'Gamepass' itemType 152 | 153 | ## [2.1.7] 154 | 155 | - Added country code to events to get correct country of users 156 | 157 | ## [2.1.6] 158 | 159 | - Corrected install instructions 160 | 161 | ## [2.1.5] 162 | 163 | - Moved everything from GameAnalyticsServer to GameAnalytics module and created a template server script for calling the initialize function. 164 | - Added queue for functions like addDesignEvent etc. that are called before player or GA is initialized 165 | - Renamed server init with settings script and restructured it (new usage) 166 | 167 | ## [2.1.4] 168 | 169 | - Added session_num to init request 170 | 171 | ## [2.1.3] 172 | 173 | - Replaced previous HMAC + SHA256 + Base64 implementation with HashLib. This version is around 23 - 25% faster. 174 | - Changed indenting from spaces to tabs (Roblox default). 175 | - Worked on reformatting so it followed the Roblox Lua style guide a little better. 176 | - Updated the luacheck files more. 177 | 178 | ## [2.1.2] 179 | 180 | - Fixed rojo file 181 | 182 | ## [2.1.1] 183 | 184 | - Updated postie script 185 | 186 | ## [2.1.0] 187 | 188 | - Added website game pass purchase tracking support 189 | 190 | ## [2.0.1] 191 | 192 | - Remote configs fixes 193 | 194 | ## [2.0.0] 195 | 196 | - Remote Config calls have been updated and the old calls have deprecated. Please see GA documentation for the new SDK calls and migration guide 197 | - A/B testing support added 198 | 199 | ## [1.4.2] 200 | 201 | - Improvements for business event 202 | 203 | ## [1.4.1] 204 | 205 | - Fix to playerRemoved function 206 | 207 | ## [1.4.0] 208 | 209 | - Added bindable event to listen to when player is ready (has gotten its player data loaded) 210 | 211 | ## [1.3.9] 212 | 213 | - Started using new bit module instead of old one 214 | 215 | ## [1.3.8] 216 | 217 | - Fixes for progression events 218 | 219 | ## [1.3.7] 220 | 221 | - Bug fix for platform name fallback option 222 | 223 | ## [1.3.6] 224 | 225 | - Fix for command center populated events 226 | 227 | ## [1.3.5] 228 | 229 | - Fixes to some types of events not being sent 230 | 231 | ## [1.3.4] 232 | 233 | - Fixed bug with automatic error events 234 | 235 | ## [1.3.3] 236 | 237 | - Fixed bug with error events not sending (another one) 238 | 239 | ## [1.3.2] 240 | 241 | - Fixed bug with error events not sending 242 | 243 | ## [1.3.1] 244 | 245 | - Fixed multi-place game bugs 246 | 247 | ## [1.3.0] 248 | 249 | - Added support for multi-place game sessions 250 | 251 | ## [1.2.13] 252 | 253 | - Changed Postie from being a script to a modulescript 254 | 255 | ## [1.2.12] 256 | 257 | - Added Postie module to replace invokeclient call in playerjoined 258 | 259 | ## [1.2.11] 260 | 261 | - Fixed playerjoined method to not wait indefinitely in some cases 262 | 263 | ## [1.2.10] 264 | 265 | - Fixed playerjoined method to not wait indefinitely in some cases 266 | 267 | ## [1.2.9] 268 | 269 | - Fixed load table bug 270 | 271 | ## [1.2.8] 272 | 273 | - Added missing files to rbxmx 274 | 275 | ## [1.2.7] 276 | 277 | - Performance to enum lookups 278 | 279 | ## [1.2.6] 280 | 281 | - Added limit to how many events there can max be in the events queue 282 | 283 | ## [1.2.5] 284 | 285 | - Added better error handling for thread task execution 286 | 287 | ## [1.2.4] 288 | 289 | - Added toggle function for debug logging in studio mode 290 | - Threading performance fix 291 | 292 | ## [1.2.3] 293 | 294 | - Various bug fixes 295 | 296 | ## [1.2.2] 297 | 298 | - Bug fixes to manual configuration and initialization of sdk 299 | 300 | ## [1.2.1] 301 | 302 | - Updated server scripts to just be descendants of ServerScriptService and not just direct child of ServerScriptService 303 | 304 | ## [1.2.0] 305 | 306 | - Added enable/disable event submission function 307 | 308 | ## [1.1.0] 309 | 310 | - Moved settings related code in GameAnalyticsServer script into a new script called GameAnalyticsServerInitUsingSettings to allow manual initialization from own script (OPS look at new INSTALL instructions for new script) 311 | 312 | ## [1.0.5] 313 | 314 | - Renamed GameAnalyticsScript to GameAnalyticsServer 315 | - Removed script location restriction on GameAnalyticsClient 316 | 317 | ## [1.0.4] 318 | 319 | - Small corrections 320 | 321 | ## [1.0.3] 322 | 323 | - Fixed automatic sending of error events 324 | - Added script for generating rbxmx file 325 | 326 | ## [1.0.2] 327 | 328 | - Fixed sha256 performance issues 329 | - Added processReceiptCallback function to use within your own processReceipt method 330 | - Replaced all string.len and table.getn with # operator instead 331 | - Using game:GetService() to access services instead of using game.[some_service] 332 | - Fixed device recognition method 333 | - Fixed automatic sending of error events 334 | 335 | ## [1.0.1] 336 | 337 | - Small bugs fixes 338 | 339 | ## [1.0.0] - Initial Release 340 | 341 | - Added session tracking 342 | - Implemented custom event logging 343 | - Integrated teleport data handling -------------------------------------------------------------------------------- /ExampleProject/.gitignore: -------------------------------------------------------------------------------- 1 | Packages/ 2 | wally.lock 3 | -------------------------------------------------------------------------------- /ExampleProject/README.md: -------------------------------------------------------------------------------- 1 | # TestProject 2 | Generated by [Rojo](https://github.com/rojo-rbx/rojo) 7.0.0. 3 | 4 | ## Getting Started 5 | To build the place from scratch, use: 6 | 7 | ```bash 8 | rojo build -o "TestProject.rbxlx" 9 | ``` 10 | 11 | Next, open `TestProject.rbxlx` in Roblox Studio and start the Rojo server: 12 | 13 | ```bash 14 | rojo serve 15 | ``` 16 | 17 | For more help, check out [the Rojo documentation](https://rojo.space/docs). -------------------------------------------------------------------------------- /ExampleProject/default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GameAnalyticsSDK test project", 3 | "tree": { 4 | "$className": "DataModel", 5 | 6 | "ReplicatedStorage": { 7 | "$className": "ReplicatedStorage", 8 | "Common": { 9 | "$path": "src/shared" 10 | }, 11 | "GameAnalytics": { 12 | "$path": "../default.project.json" 13 | } 14 | }, 15 | 16 | "ServerScriptService": { 17 | "$className": "ServerScriptService", 18 | "Server": { 19 | "$path": "src/server" 20 | } 21 | }, 22 | 23 | "StarterPlayer": { 24 | "$className": "StarterPlayer", 25 | "StarterPlayerScripts": { 26 | "$className": "StarterPlayerScripts", 27 | "Client": { 28 | "$path": "src/client" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ExampleProject/foreman.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | rojo = { source = "rojo-rbx/rojo", version = "=7.0.0" } 3 | wally = { source = "UpliftGames/wally", version = "=0.3.1" } 4 | -------------------------------------------------------------------------------- /ExampleProject/src/client/init.client.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | -- using wally package 3 | --local GameAnalytics = require(ReplicatedStorage.Packages.GameAnalytics) 4 | -- using rojo or manually copied in 5 | local GameAnalytics = require(ReplicatedStorage.GameAnalytics) 6 | 7 | GameAnalytics:initClient() 8 | -------------------------------------------------------------------------------- /ExampleProject/src/server/init.server.lua: -------------------------------------------------------------------------------- 1 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 2 | -- using wally package 3 | --local GameAnalytics = require(ReplicatedStorage.Packages.GameAnalytics) 4 | -- using rojo or manually copied in 5 | local GameAnalytics = require(ReplicatedStorage.GameAnalytics) 6 | 7 | GameAnalytics:setEnabledInfoLog(true) 8 | GameAnalytics:setEnabledVerboseLog(true) 9 | 10 | GameAnalytics:initServer("MY_GAME_KEY", "MY_SECRET_KEY") 11 | -------------------------------------------------------------------------------- /ExampleProject/src/shared/module.lua: -------------------------------------------------------------------------------- 1 | local module = {} 2 | 3 | return module 4 | -------------------------------------------------------------------------------- /ExampleProject/wally.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "GameAnalyticsSDK test project", 3 | "tree": { 4 | "$className": "DataModel", 5 | 6 | "ReplicatedStorage": { 7 | "$className": "ReplicatedStorage", 8 | "Common": { 9 | "$path": "src/shared" 10 | }, 11 | "Packages": { 12 | "$path": "Packages" 13 | } 14 | }, 15 | 16 | "ServerScriptService": { 17 | "$className": "ServerScriptService", 18 | "Server": { 19 | "$path": "src/server" 20 | } 21 | }, 22 | 23 | "StarterPlayer": { 24 | "$className": "StarterPlayer", 25 | "StarterPlayerScripts": { 26 | "$className": "StarterPlayerScripts", 27 | "Client": { 28 | "$path": "src/client" 29 | } 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /ExampleProject/wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gameanalytics/exampleproject" 3 | version = "0.1.0" 4 | registry = "https://github.com/UpliftGames/wally-index" 5 | realm = "shared" 6 | 7 | [dependencies] 8 | GameAnalytics = "gameanalytics/gameanalytics-sdk@2.2.0" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Game Analytics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GA-SDK-ROBLOX 2 | 3 | GameAnalytics Roblox SDK. 4 | 5 | Documentation can be found [here](https://docs.gameanalytics.com/integrations/sdk/roblox). 6 | 7 | If you have any issues or feedback regarding the SDK, please contact our friendly support team [here](https://gameanalytics.com/contact). 8 | 9 | ## Changelog 10 | 11 | For a detailed list of changes, see the [CHANGELOG](CHANGELOG.md). 12 | 13 | ## Requirements 14 | 15 | * [rojo](https://github.com/LPGhatguy/rojo) (optional, but needed if you want to automatically sync the source files inside the GameAnalyticsSDK folder into your Roblox project) 16 | 17 | ## Installation and Usage 18 | 19 | For detailed installation and usage instructions, please refer to our [official documentation](https://docs.gameanalytics.com/integrations/sdk/roblox). 20 | 21 | ## Contributing 22 | 23 | We welcome contributions! Please follow these steps to contribute: 24 | 25 | 1. Fork the repository. 26 | 2. Create a new branch (`git checkout -b feature-branch`). 27 | 3. Commit your changes (`git commit -m 'Add new feature'`). 28 | 4. Push to the branch (`git push origin feature-branch`). 29 | 5. Open a pull request. 30 | 31 | ## License 32 | 33 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 34 | -------------------------------------------------------------------------------- /aftman.toml: -------------------------------------------------------------------------------- 1 | # This file lists tools managed by Aftman, a cross-platform toolchain manager. 2 | # For more information, see https://github.com/LPGhatguy/aftman 3 | 4 | # To add a new tool, add an entry to this table. 5 | [tools] 6 | rojo = "rojo-rbx/rojo@7.4.1" 7 | selene = "kampfkarren/selene@0.25.0" 8 | stylua = "johnnymorganz/stylua@0.19.1" 9 | -------------------------------------------------------------------------------- /default.project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gameanalytics-sdk", 3 | "tree": { 4 | "$path": "gameanalytics-sdk" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Events.lua: -------------------------------------------------------------------------------- 1 | local events = { 2 | ProcessEventsInterval = 8, 3 | GameKey = "", 4 | SecretKey = "", 5 | Build = "", 6 | _availableResourceCurrencies = {}, 7 | _availableResourceItemTypes = {}, 8 | } 9 | 10 | local store = require(script.Parent.Store) 11 | local logger = require(script.Parent.Logger) 12 | local version = require(script.Parent.Version) 13 | local validation = require(script.Parent.Validation) 14 | local threading = require(script.Parent.Threading) 15 | local http_api = require(script.Parent.HttpApi) 16 | local utilities = require(script.Parent.Utilities) 17 | local GAResourceFlowType = require(script.Parent.GAResourceFlowType) 18 | local GAProgressionStatus = require(script.Parent.GAProgressionStatus) 19 | local GAErrorSeverity = require(script.Parent.GAErrorSeverity) 20 | local HTTP = game:GetService("HttpService") 21 | 22 | local CategorySessionStart = "user" 23 | local CategorySessionEnd = "session_end" 24 | local CategoryBusiness = "business" 25 | local CategoryResource = "resource" 26 | local CategoryProgression = "progression" 27 | local CategoryDesign = "design" 28 | local CategoryError = "error" 29 | local CategorySdkError = "sdk_error" 30 | local MAX_EVENTS_TO_SEND_IN_ONE_BATCH = 500 31 | local MAX_AGGREGATED_EVENTS = 2000 32 | 33 | local function addCustomFieldsToEvent(eventData, customFields) 34 | if not (eventData and customFields) then 35 | return 36 | end 37 | 38 | local fields = {} 39 | 40 | for key, value in pairs(customFields) do 41 | local v = tostring(value) 42 | if #v > 256 then 43 | logger:w("Custom field value is too long. Max length is 256 characters. Field: " .. key) 44 | v = string.sub(v, 1, 256) 45 | end 46 | 47 | fields[key] = v 48 | end 49 | 50 | if fields and next(fields) then 51 | eventData["custom_fields"] = fields 52 | end 53 | end 54 | 55 | local function addDimensionsToEvent(playerId, eventData) 56 | if not eventData or not playerId then 57 | return 58 | end 59 | 60 | local PlayerData = store:GetPlayerDataFromCache(playerId) 61 | 62 | -- add to dict (if not nil) 63 | if PlayerData and PlayerData.CurrentCustomDimension01 and #PlayerData.CurrentCustomDimension01 > 0 then 64 | eventData["custom_01"] = PlayerData.CurrentCustomDimension01 65 | end 66 | 67 | if PlayerData and PlayerData.CurrentCustomDimension02 and #PlayerData.CurrentCustomDimension02 > 0 then 68 | eventData["custom_02"] = PlayerData.CurrentCustomDimension02 69 | end 70 | 71 | if PlayerData and PlayerData.CurrentCustomDimension03 and #PlayerData.CurrentCustomDimension03 > 0 then 72 | eventData["custom_03"] = PlayerData.CurrentCustomDimension03 73 | end 74 | end 75 | 76 | local function getClientTsAdjusted(playerId) 77 | if not playerId then 78 | return os.time() 79 | end 80 | 81 | local PlayerData = store:GetPlayerDataFromCache(playerId) 82 | local clientTs = os.time() 83 | local clientTsAdjustedInteger = clientTs + PlayerData.ClientServerTimeOffset 84 | if validation:validateClientTs(clientTsAdjustedInteger) then 85 | return clientTsAdjustedInteger 86 | else 87 | return clientTs 88 | end 89 | end 90 | 91 | local DUMMY_SESSION_ID = HTTP:GenerateGUID(false):lower() 92 | 93 | local function Length(Table) 94 | local counter = 0 95 | for _, _ in pairs(Table) do 96 | counter += 1 97 | end 98 | return counter 99 | end 100 | 101 | local function getEventAnnotations(playerId) 102 | local PlayerData 103 | local id 104 | 105 | if playerId then 106 | id = playerId 107 | PlayerData = store:GetPlayerDataFromCache(playerId) 108 | else 109 | id = "DummyId" 110 | PlayerData = { 111 | OS = "uwp_desktop 0.0.0", 112 | Platform = "uwp_desktop", 113 | SessionID = DUMMY_SESSION_ID, 114 | Sessions = 1, 115 | CustomUserId = "Server", 116 | } 117 | end 118 | 119 | local annotations = { 120 | -- ---- REQUIRED ---- 121 | -- collector event API version 122 | ["v"] = 2, 123 | -- User identifier 124 | ["user_id"] = tostring(id) .. PlayerData.CustomUserId, 125 | -- Client Timestamp (the adjusted timestamp) 126 | ["client_ts"] = getClientTsAdjusted(playerId), 127 | -- SDK version 128 | ["sdk_version"] = "roblox " .. version.SdkVersion, 129 | -- Operation system version 130 | ["os_version"] = PlayerData.OS, 131 | -- Device make (hardcoded to apple) 132 | ["manufacturer"] = "unknown", 133 | -- Device version 134 | ["device"] = "unknown", 135 | -- Platform (operating system) 136 | ["platform"] = PlayerData.Platform, 137 | -- Session identifier 138 | ["session_id"] = PlayerData.SessionID, 139 | -- Session number 140 | ["session_num"] = PlayerData.Sessions, 141 | } 142 | 143 | if not utilities:isStringNullOrEmpty(PlayerData.CountryCode) then 144 | annotations["country_code"] = PlayerData.CountryCode 145 | else 146 | annotations["country_code"] = "unknown" 147 | end 148 | 149 | if validation:validateBuild(events.Build) then 150 | annotations["build"] = events.Build 151 | end 152 | 153 | if PlayerData.Configurations and Length(PlayerData.Configurations) > 0 then 154 | annotations["configurations"] = PlayerData.Configurations 155 | end 156 | 157 | if not utilities:isStringNullOrEmpty(PlayerData.AbId) then 158 | annotations["ab_id"] = PlayerData.AbId 159 | end 160 | 161 | if not utilities:isStringNullOrEmpty(PlayerData.AbVariantId) then 162 | annotations["ab_variant_id"] = PlayerData.AbVariantId 163 | end 164 | 165 | return annotations 166 | end 167 | 168 | local function addEventToStore(playerId, eventData) 169 | -- Get default annotations 170 | local ev = getEventAnnotations(playerId) 171 | 172 | -- Merge with eventData 173 | for k in pairs(eventData) do 174 | ev[k] = eventData[k] 175 | end 176 | 177 | -- Create json string representation 178 | local json = HTTP:JSONEncode(ev) 179 | 180 | -- output if VERBOSE LOG enabled 181 | logger:ii("Event added to queue: " .. json) 182 | 183 | -- Add to store 184 | store.EventsQueue[#store.EventsQueue + 1] = ev 185 | end 186 | 187 | local function dequeueMaxEvents() 188 | if #store.EventsQueue <= MAX_EVENTS_TO_SEND_IN_ONE_BATCH then 189 | local eventsQueue = store.EventsQueue 190 | store.EventsQueue = {} 191 | return eventsQueue 192 | else 193 | logger:w( 194 | ("More than %d events queued! Sending %d."):format( 195 | MAX_EVENTS_TO_SEND_IN_ONE_BATCH, 196 | MAX_EVENTS_TO_SEND_IN_ONE_BATCH 197 | ) 198 | ) 199 | 200 | if #store.EventsQueue > MAX_AGGREGATED_EVENTS then 201 | logger:w(("DROPPING EVENTS: More than %d events queued!"):format(MAX_AGGREGATED_EVENTS)) 202 | end 203 | 204 | -- Expensive operation to get ordered events cleared out (O(n)) 205 | local eventsQueue = table.create(MAX_EVENTS_TO_SEND_IN_ONE_BATCH) 206 | for i = 1, MAX_EVENTS_TO_SEND_IN_ONE_BATCH do 207 | eventsQueue[i] = store.EventsQueue[i] 208 | end 209 | 210 | -- Shift everything down and overwrite old events 211 | local eventCount = #store.EventsQueue 212 | for i = 1, math.min(MAX_AGGREGATED_EVENTS, eventCount) do 213 | store.EventsQueue[i] = store.EventsQueue[i + MAX_EVENTS_TO_SEND_IN_ONE_BATCH] 214 | end 215 | 216 | -- Clear additional events 217 | for i = MAX_AGGREGATED_EVENTS + 1, eventCount do 218 | store.EventsQueue[i] = nil 219 | end 220 | 221 | return eventsQueue 222 | end 223 | end 224 | 225 | local function processEvents() 226 | local queue = dequeueMaxEvents() 227 | 228 | if #queue == 0 then 229 | logger:i("Event queue: No events to send") 230 | return 231 | end 232 | 233 | -- Log 234 | logger:i("Event queue: Sending " .. tostring(#queue) .. " events.") 235 | 236 | local eventsResult = http_api:sendEventsInArray(events.GameKey, events.SecretKey, queue) 237 | local statusCode = eventsResult.statusCode 238 | local responseBody = eventsResult.body 239 | 240 | if statusCode == http_api.EGAHTTPApiResponse.Ok and responseBody then 241 | logger:i("Event queue: " .. tostring(#queue) .. " events sent.") 242 | else 243 | if statusCode == http_api.EGAHTTPApiResponse.NoResponse then 244 | logger:w("Event queue: Failed to send events to collector - Retrying next time") 245 | for _, e in pairs(queue) do 246 | if #store.EventsQueue < MAX_AGGREGATED_EVENTS then 247 | store.EventsQueue[#store.EventsQueue + 1] = e 248 | else 249 | break 250 | end 251 | end 252 | else 253 | if statusCode == http_api.EGAHTTPApiResponse.BadRequest and responseBody then 254 | logger:w( 255 | "Event queue: " 256 | .. tostring(#queue) 257 | .. " events sent. " 258 | .. tostring(#responseBody) 259 | .. " events failed GA server validation." 260 | ) 261 | else 262 | logger:w("Event queue: Failed to send events.") 263 | end 264 | end 265 | end 266 | end 267 | 268 | function events:processEventQueue() 269 | processEvents() 270 | threading:scheduleTimer(events.ProcessEventsInterval, function() 271 | events:processEventQueue() 272 | end) 273 | end 274 | 275 | function events:setBuild(build) 276 | if not validation:validateBuild(build) then 277 | logger:w("Validation fail - configure build: Cannot be null, empty or above 32 length. String: " .. build) 278 | return 279 | end 280 | 281 | self.Build = build 282 | logger:i("Set build version: " .. build) 283 | end 284 | 285 | function events:setAvailableResourceCurrencies(availableResourceCurrencies) 286 | if not validation:validateResourceCurrencies(availableResourceCurrencies) then 287 | return 288 | end 289 | 290 | self._availableResourceCurrencies = availableResourceCurrencies 291 | logger:i("Set available resource currencies: (" .. table.concat(availableResourceCurrencies, ", ") .. ")") 292 | end 293 | 294 | function events:setAvailableResourceItemTypes(availableResourceItemTypes) 295 | if not validation:validateResourceCurrencies(availableResourceItemTypes) then 296 | return 297 | end 298 | 299 | self._availableResourceItemTypes = availableResourceItemTypes 300 | logger:i("Set available resource item types: (" .. table.concat(availableResourceItemTypes, ", ") .. ")") 301 | end 302 | 303 | function events:addSessionStartEvent(playerId, teleportData, customFields) 304 | local PlayerData = store:GetPlayerDataFromCache(playerId) 305 | 306 | if teleportData then 307 | PlayerData.Sessions = teleportData.Sessions 308 | else 309 | local eventDict = {} 310 | 311 | -- Event specific data 312 | eventDict["category"] = CategorySessionStart 313 | 314 | -- Increment session number and persist 315 | PlayerData.Sessions = PlayerData.Sessions + 1 316 | 317 | -- Add custom dimensions 318 | addDimensionsToEvent(playerId, eventDict) 319 | 320 | -- Add to store 321 | addEventToStore(playerId, eventDict) 322 | addCustomFieldsToEvent(eventDict, customFields) 323 | 324 | logger:i("Add SESSION START event") 325 | 326 | processEvents() 327 | end 328 | end 329 | 330 | function events:addSessionEndEvent(playerId, customFields) 331 | local PlayerData = store:GetPlayerDataFromCache(playerId) 332 | local session_start_ts = PlayerData.SessionStart 333 | local client_ts_adjusted = getClientTsAdjusted(playerId) 334 | local sessionLength = 0 335 | 336 | if client_ts_adjusted ~= nil and session_start_ts ~= nil then 337 | sessionLength = client_ts_adjusted - session_start_ts 338 | end 339 | 340 | if sessionLength < 0 then 341 | -- Should never happen. 342 | -- Could be because of edge cases regarding time altering on device. 343 | logger:w("Session length was calculated to be less then 0. Should not be possible. Resetting to 0.") 344 | sessionLength = 0 345 | end 346 | 347 | -- Event specific data 348 | local eventDict = {} 349 | eventDict["category"] = CategorySessionEnd 350 | eventDict["length"] = sessionLength 351 | 352 | -- Add custom dimensions 353 | addDimensionsToEvent(playerId, eventDict) 354 | addCustomFieldsToEvent(eventDict, customFields) 355 | 356 | -- Add to store 357 | addEventToStore(playerId, eventDict) 358 | PlayerData.SessionStart = 0 359 | 360 | logger:i("Add SESSION END event.") 361 | 362 | processEvents() 363 | end 364 | 365 | function events:addBusinessEvent(playerId, currency, amount, itemType, itemId, cartType, customFields) 366 | -- Validate event params 367 | if not validation:validateBusinessEvent(currency, amount, cartType, itemType, itemId) then 368 | -- TODO: add sdk error event 369 | return 370 | end 371 | 372 | -- Create empty eventData 373 | local eventDict = {} 374 | 375 | -- Increment transaction number and persist 376 | local PlayerData = store:GetPlayerDataFromCache(playerId) 377 | PlayerData.Transactions = PlayerData.Transactions + 1 378 | 379 | -- Required 380 | eventDict["event_id"] = itemType .. ":" .. itemId 381 | eventDict["category"] = CategoryBusiness 382 | eventDict["currency"] = currency 383 | eventDict["amount"] = amount 384 | eventDict["transaction_num"] = PlayerData.Transactions 385 | 386 | -- Optional 387 | if not utilities:isStringNullOrEmpty(cartType) then 388 | eventDict["cart_type"] = cartType 389 | end 390 | 391 | -- Add custom dimensions 392 | addDimensionsToEvent(playerId, eventDict) 393 | addCustomFieldsToEvent(eventDict, customFields) 394 | 395 | logger:i( 396 | "Add BUSINESS event: {currency:" 397 | .. currency 398 | .. ", amount:" 399 | .. tostring(amount) 400 | .. ", itemType:" 401 | .. itemType 402 | .. ", itemId:" 403 | .. itemId 404 | .. ", cartType:" 405 | .. cartType 406 | .. "}" 407 | ) 408 | 409 | -- Send to store 410 | addEventToStore(playerId, eventDict) 411 | end 412 | 413 | function events:addResourceEvent(playerId, flowType, currency, amount, itemType, itemId, customFields) 414 | -- Validate event params 415 | if 416 | not validation:validateResourceEvent( 417 | GAResourceFlowType, 418 | flowType, 419 | currency, 420 | amount, 421 | itemType, 422 | itemId, 423 | self._availableResourceCurrencies, 424 | self._availableResourceItemTypes 425 | ) 426 | then 427 | -- TODO: add sdk error event 428 | return 429 | end 430 | 431 | -- If flow type is sink reverse amount 432 | if flowType == GAResourceFlowType.Sink then 433 | amount = (-1 * amount) 434 | end 435 | 436 | -- Create empty eventData 437 | local eventDict = {} 438 | 439 | -- insert event specific values 440 | local flowTypeString = GAResourceFlowType[flowType] 441 | eventDict["event_id"] = flowTypeString .. ":" .. currency .. ":" .. itemType .. ":" .. itemId 442 | eventDict["category"] = CategoryResource 443 | eventDict["amount"] = amount 444 | 445 | -- Add custom dimensions 446 | addDimensionsToEvent(playerId, eventDict) 447 | addCustomFieldsToEvent(eventDict, customFields) 448 | 449 | logger:i( 450 | "Add RESOURCE event: {currency:" 451 | .. currency 452 | .. ", amount:" 453 | .. tostring(amount) 454 | .. ", itemType:" 455 | .. itemType 456 | .. ", itemId:" 457 | .. itemId 458 | .. "}" 459 | ) 460 | 461 | -- Send to store 462 | addEventToStore(playerId, eventDict) 463 | end 464 | 465 | function events:addProgressionEvent( 466 | playerId, 467 | progressionStatus, 468 | progression01, 469 | progression02, 470 | progression03, 471 | score, 472 | customFields 473 | ) 474 | -- Validate event params 475 | if 476 | not validation:validateProgressionEvent( 477 | GAProgressionStatus, 478 | progressionStatus, 479 | progression01, 480 | progression02, 481 | progression03 482 | ) 483 | then 484 | -- TODO: add sdk error event 485 | return 486 | end 487 | 488 | -- Create empty eventData 489 | local eventDict = {} 490 | 491 | -- Progression identifier 492 | local progressionIdentifier 493 | if utilities:isStringNullOrEmpty(progression02) then 494 | progressionIdentifier = progression01 495 | elseif utilities:isStringNullOrEmpty(progression03) then 496 | progressionIdentifier = progression01 .. ":" .. progression02 497 | else 498 | progressionIdentifier = progression01 .. ":" .. progression02 .. ":" .. progression03 499 | end 500 | 501 | local statusString = GAProgressionStatus[progressionStatus] 502 | 503 | -- Append event specifics 504 | eventDict["category"] = CategoryProgression 505 | eventDict["event_id"] = statusString .. ":" .. progressionIdentifier 506 | 507 | -- Attempt 508 | local attempt_num = 0 509 | 510 | -- Add score if specified and status is not start 511 | if score ~= nil and progressionStatus ~= GAProgressionStatus.Start then 512 | eventDict["score"] = score 513 | end 514 | 515 | local PlayerData = store:GetPlayerDataFromCache(playerId) 516 | 517 | -- Count attempts on each progression fail and persist 518 | if progressionStatus == GAProgressionStatus.Fail then 519 | -- Increment attempt number 520 | local progressionTries = PlayerData.ProgressionTries[progressionIdentifier] or 0 521 | PlayerData.ProgressionTries[progressionIdentifier] = progressionTries + 1 522 | end 523 | 524 | -- increment and add attempt_num on complete and delete persisted 525 | if progressionStatus == GAProgressionStatus.Complete then 526 | -- Increment attempt number 527 | local progressionTries = PlayerData.ProgressionTries[progressionIdentifier] or 0 528 | PlayerData.ProgressionTries[progressionIdentifier] = progressionTries + 1 529 | 530 | -- Add to event 531 | attempt_num = PlayerData.ProgressionTries[progressionIdentifier] 532 | eventDict["attempt_num"] = attempt_num 533 | 534 | -- Clear 535 | PlayerData.ProgressionTries[progressionIdentifier] = 0 536 | end 537 | 538 | -- Add custom dimensions 539 | addDimensionsToEvent(playerId, eventDict) 540 | addCustomFieldsToEvent(eventDict, customFields) 541 | 542 | local progression02String = "" 543 | if not utilities:isStringNullOrEmpty(progression02) then 544 | progression02String = progression02 545 | end 546 | 547 | local progression03String = "" 548 | if not utilities:isStringNullOrEmpty(progression03) then 549 | progression03String = progression03 550 | end 551 | 552 | logger:i( 553 | "Add PROGRESSION event: {status:" 554 | .. statusString 555 | .. ", progression01:" 556 | .. progression01 557 | .. ", progression02:" 558 | .. progression02String 559 | .. ", progression03:" 560 | .. progression03String 561 | .. ", score:" 562 | .. tostring(score) 563 | .. ", attempt:" 564 | .. tostring(attempt_num) 565 | .. "}" 566 | ) 567 | 568 | -- Send to store 569 | addEventToStore(playerId, eventDict) 570 | end 571 | 572 | function events:addDesignEvent(playerId, eventId, value, customFields) 573 | -- Validate 574 | if not validation:validateDesignEvent(eventId) then 575 | -- TODO: add sdk error event 576 | return 577 | end 578 | 579 | -- Create empty eventData 580 | local eventData = {} 581 | 582 | -- Append event specifics 583 | eventData["category"] = CategoryDesign 584 | eventData["event_id"] = eventId 585 | 586 | if value ~= nil then 587 | eventData["value"] = value 588 | end 589 | 590 | -- Add custom dimensions 591 | addDimensionsToEvent(playerId, eventData) 592 | addCustomFieldsToEvent(eventData, customFields) 593 | 594 | logger:i("Add DESIGN event: {eventId:" .. eventId .. ", value:" .. tostring(value) .. "}") 595 | 596 | -- Send to store 597 | addEventToStore(playerId, eventData) 598 | end 599 | 600 | function events:addErrorEvent(playerId, severity, message, customFields) 601 | -- Validate 602 | if not validation:validateErrorEvent(GAErrorSeverity, severity, message) then 603 | -- TODO: add sdk error event 604 | return 605 | end 606 | 607 | -- Create empty eventData 608 | local eventData = {} 609 | 610 | local severityString = GAErrorSeverity[severity] 611 | 612 | eventData["category"] = CategoryError 613 | eventData["severity"] = severityString 614 | eventData["message"] = message 615 | 616 | -- Add custom dimensions 617 | addDimensionsToEvent(playerId, eventData) 618 | addCustomFieldsToEvent(eventData, customFields) 619 | 620 | local messageString = "" 621 | if not utilities:isStringNullOrEmpty(message) then 622 | messageString = message 623 | end 624 | 625 | logger:i("Add ERROR event: {severity:" .. severityString .. ", message:" .. messageString .. "}") 626 | 627 | -- Send to store 628 | addEventToStore(playerId, eventData) 629 | end 630 | 631 | function events:addSdkErrorEvent(playerId, category, area, action, parameter, reason) 632 | -- Create empty eventData 633 | local eventData = {} 634 | 635 | eventData["category"] = CategorySdkError 636 | eventData["error_category"] = category 637 | eventData["error_area"] = area 638 | eventData["error_action"] = action 639 | 640 | if not utilities:isStringNullOrEmpty(parameter) then 641 | eventData["error_parameter"] = parameter 642 | end 643 | 644 | if not utilities:isStringNullOrEmpty(reason) then 645 | eventData["reason"] = reason 646 | end 647 | 648 | logger:i( 649 | "Add SDK ERROR event: {error_category:" 650 | .. category 651 | .. ", error_area:" 652 | .. area 653 | .. ", error_action:" 654 | .. action 655 | .. "}" 656 | ) 657 | 658 | -- Send to store 659 | addEventToStore(playerId, eventData) 660 | end 661 | 662 | return events 663 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/GAErrorSeverity.lua: -------------------------------------------------------------------------------- 1 | local function readonlytable(table) 2 | return setmetatable({}, { 3 | __index = table, 4 | __metatable = false, 5 | __newindex = function(t, k, v) 6 | error("Attempt to modify read-only table: " .. t .. ", key=" .. k .. ", value=" .. v) 7 | end, 8 | }) 9 | end 10 | 11 | return readonlytable({ 12 | debug = "debug", 13 | info = "info", 14 | warning = "warning", 15 | error = "error", 16 | critical = "critical", 17 | }) 18 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/GAProgressionStatus.lua: -------------------------------------------------------------------------------- 1 | local function readonlytable(table) 2 | return setmetatable({}, { 3 | __index = table, 4 | __metatable = false, 5 | __newindex = function(t, k, v) 6 | error("Attempt to modify read-only table: " .. t .. ", key=" .. k .. ", value=" .. v) 7 | end, 8 | }) 9 | end 10 | 11 | return readonlytable({ 12 | Start = "Start", 13 | Complete = "Complete", 14 | Fail = "Fail", 15 | }) 16 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/GAResourceFlowType.lua: -------------------------------------------------------------------------------- 1 | local function readonlytable(table) 2 | return setmetatable({}, { 3 | __index = table, 4 | __metatable = false, 5 | __newindex = function(t, k, v) 6 | error("Attempt to modify read-only table: " .. t .. ", key=" .. k .. ", value=" .. v) 7 | end, 8 | }) 9 | end 10 | 11 | return readonlytable({ 12 | Source = "Source", 13 | Sink = "Sink", 14 | }) 15 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/HttpApi/HashLib/Base64.lua: -------------------------------------------------------------------------------- 1 | -- @original: https://gist.github.com/Reselim/40d62b17d138cc74335a1b0709e19ce2 2 | local Alphabet = {} 3 | local Indexes = {} 4 | 5 | -- A-Z 6 | for Index = 65, 90 do 7 | table.insert(Alphabet, Index) 8 | end 9 | 10 | -- a-z 11 | for Index = 97, 122 do 12 | table.insert(Alphabet, Index) 13 | end 14 | 15 | -- 0-9 16 | for Index = 48, 57 do 17 | table.insert(Alphabet, Index) 18 | end 19 | 20 | table.insert(Alphabet, 43) -- + 21 | table.insert(Alphabet, 47) -- / 22 | 23 | for Index, Character in ipairs(Alphabet) do 24 | Indexes[Character] = Index 25 | end 26 | 27 | local Base64 = {} 28 | 29 | local bit32_rshift = bit32.rshift 30 | local bit32_lshift = bit32.lshift 31 | local bit32_band = bit32.band 32 | 33 | --[[** 34 | Encodes a string in Base64. 35 | @param [t:string] Input The input string to encode. 36 | @returns [t:string] The string encoded in Base64. 37 | **--]] 38 | function Base64.Encode(Input) 39 | local Output = {} 40 | local Length = 0 41 | 42 | for Index = 1, #Input, 3 do 43 | local C1, C2, C3 = string.byte(Input, Index, Index + 2) 44 | 45 | local A = bit32_rshift(C1, 2) 46 | local B = bit32_lshift(bit32_band(C1, 3), 4) + bit32_rshift(C2 or 0, 4) 47 | local C = bit32_lshift(bit32_band(C2 or 0, 15), 2) + bit32_rshift(C3 or 0, 6) 48 | local D = bit32_band(C3 or 0, 63) 49 | 50 | Length = Length + 1 51 | Output[Length] = Alphabet[A + 1] 52 | 53 | Length = Length + 1 54 | Output[Length] = Alphabet[B + 1] 55 | 56 | Length = Length + 1 57 | Output[Length] = C2 and Alphabet[C + 1] or 61 58 | 59 | Length = Length + 1 60 | Output[Length] = C3 and Alphabet[D + 1] or 61 61 | end 62 | 63 | local NewOutput = {} 64 | local NewLength = 0 65 | local IndexAdd4096Sub1 66 | 67 | for Index = 1, Length, 4096 do 68 | NewLength = NewLength + 1 69 | IndexAdd4096Sub1 = Index + 4096 - 1 70 | 71 | NewOutput[NewLength] = 72 | string.char(table.unpack(Output, Index, IndexAdd4096Sub1 > Length and Length or IndexAdd4096Sub1)) 73 | end 74 | 75 | return table.concat(NewOutput) 76 | end 77 | 78 | --[[** 79 | Decodes a string from Base64. 80 | @param [t:string] Input The input string to decode. 81 | @returns [t:string] The newly decoded string. 82 | **--]] 83 | function Base64.Decode(Input) 84 | local Output = {} 85 | local Length = 0 86 | 87 | for Index = 1, #Input, 4 do 88 | local C1, C2, C3, C4 = string.byte(Input, Index, Index + 3) 89 | 90 | local I1 = Indexes[C1] - 1 91 | local I2 = Indexes[C2] - 1 92 | local I3 = (Indexes[C3] or 1) - 1 93 | local I4 = (Indexes[C4] or 1) - 1 94 | 95 | local A = bit32_lshift(I1, 2) + bit32_rshift(I2, 4) 96 | local B = bit32_lshift(bit32_band(I2, 15), 4) + bit32_rshift(I3, 2) 97 | local C = bit32_lshift(bit32_band(I3, 3), 6) + I4 98 | 99 | Length = Length + 1 100 | Output[Length] = A 101 | 102 | if C3 ~= 61 then 103 | Length = Length + 1 104 | Output[Length] = B 105 | end 106 | 107 | if C4 ~= 61 then 108 | Length = Length + 1 109 | Output[Length] = C 110 | end 111 | end 112 | 113 | local NewOutput = {} 114 | local NewLength = 0 115 | local IndexAdd4096Sub1 116 | 117 | for Index = 1, Length, 4096 do 118 | NewLength = NewLength + 1 119 | IndexAdd4096Sub1 = Index + 4096 - 1 120 | 121 | NewOutput[NewLength] = 122 | string.char(table.unpack(Output, Index, IndexAdd4096Sub1 > Length and Length or IndexAdd4096Sub1)) 123 | end 124 | 125 | return table.concat(NewOutput) 126 | end 127 | 128 | return Base64 129 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/HttpApi/HashLib/init.lua: -------------------------------------------------------------------------------- 1 | --[=[------------------------------------------------------------------------------------------------------------------------ 2 | -- HashLib by Egor Skriptunoff, boatbomber, and howmanysmall 3 | 4 | Documentation here: https://devforum.roblox.com/t/open-source-hashlib/416732/1 5 | 6 | -------------------------------------------------------------------------------------------------------------------------- 7 | 8 | Module was originally written by Egor Skriptunoff and distributed under an MIT license. 9 | It can be found here: https://github.com/Egor-Skriptunoff/pure_lua_SHA/blob/master/sha2.lua 10 | 11 | That version was around 3000 lines long, and supported Lua versions 5.1, 5.2, 5.3, and 5.4, and LuaJIT. 12 | Although that is super cool, Roblox only uses Lua 5.1, so that was extreme overkill. 13 | 14 | I, boatbomber, worked (with howmanysmall's guidance) to port it to Roblox in a way that 15 | doesn't overcomplicate it with support of unreachable cases. Then, howmanysmall did some final optimizations 16 | that really squeeze out all the performance possible. 17 | 18 | After quite a bit of work and benchmarking, this is what we were left with. 19 | Enjoy! 20 | 21 | -------------------------------------------------------------------------------------------------------------------------- 22 | 23 | DESCRIPTION: 24 | This module contains functions to calculate SHA digest: 25 | MD5, SHA-1, 26 | SHA-224, SHA-256, SHA-512/224, SHA-512/256, SHA-384, SHA-512, 27 | SHA3-224, SHA3-256, SHA3-384, SHA3-512, SHAKE128, SHAKE256, 28 | HMAC 29 | Additionally, it has a few extra utility functions: 30 | hex_to_bin 31 | base64_to_bin 32 | bin_to_base64 33 | Written in pure Lua. 34 | USAGE: 35 | Input data should be a string 36 | Result (SHA digest) is returned in hexadecimal representation as a string of lowercase hex digits. 37 | Simplest usage example: 38 | local HashLib = require(script.HashLib) 39 | local your_hash = HashLib.sha256("your string") 40 | API: 41 | HashLib.md5 42 | HashLib.sha1 43 | SHA2 hash functions: 44 | HashLib.sha224 45 | HashLib.sha256 46 | HashLib.sha512_224 47 | HashLib.sha512_256 48 | HashLib.sha384 49 | HashLib.sha512 50 | SHA3 hash functions: 51 | HashLib.sha3_224 52 | HashLib.sha3_256 53 | HashLib.sha3_384 54 | HashLib.sha3_512 55 | HashLib.shake128 56 | HashLib.shake256 57 | Misc utilities: 58 | HashLib.hmac (Applicable to any hash function from this module except SHAKE*) 59 | HashLib.hex_to_bin 60 | HashLib.base64_to_bin 61 | HashLib.bin_to_base64 62 | 63 | --]=] 64 | --------------------------------------------------------------------------- 65 | 66 | local Base64 = require(script.Base64) 67 | 68 | -------------------------------------------------------------------------------- 69 | -- LOCALIZATION FOR VM OPTIMIZATIONS 70 | -------------------------------------------------------------------------------- 71 | 72 | local ipairs = ipairs 73 | 74 | -------------------------------------------------------------------------------- 75 | -- 32-BIT BITWISE FUNCTIONS 76 | -------------------------------------------------------------------------------- 77 | -- Only low 32 bits of function arguments matter, high bits are ignored 78 | -- The result of all functions (except HEX) is an integer inside "correct range": 79 | -- for "bit" library: (-TWO_POW_31)..(TWO_POW_31-1) 80 | -- for "bit32" library: 0..(TWO_POW_32-1) 81 | local bit32_band = bit32.band -- 2 arguments 82 | local bit32_bor = bit32.bor -- 2 arguments 83 | local bit32_bxor = bit32.bxor -- 2..5 arguments 84 | local bit32_lshift = bit32.lshift -- second argument is integer 0..31 85 | local bit32_rshift = bit32.rshift -- second argument is integer 0..31 86 | local bit32_lrotate = bit32.lrotate -- second argument is integer 0..31 87 | local bit32_rrotate = bit32.rrotate -- second argument is integer 0..31 88 | 89 | -------------------------------------------------------------------------------- 90 | -- CREATING OPTIMIZED INNER LOOP 91 | -------------------------------------------------------------------------------- 92 | -- Arrays of SHA2 "magic numbers" (in "INT64" and "FFI" branches "*_lo" arrays contain 64-bit values) 93 | local sha2_K_lo, sha2_K_hi, sha2_H_lo, sha2_H_hi, sha3_RC_lo, sha3_RC_hi = {}, {}, {}, {}, {}, {} 94 | local sha2_H_ext256 = { 95 | [224] = {}, 96 | [256] = sha2_H_hi, 97 | } 98 | 99 | local sha2_H_ext512_lo, sha2_H_ext512_hi = { 100 | [384] = {}, 101 | [512] = sha2_H_lo, 102 | }, { 103 | [384] = {}, 104 | [512] = sha2_H_hi, 105 | } 106 | 107 | local md5_K, md5_sha1_H = {}, { 0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0 } 108 | local md5_next_shift = 109 | { 0, 0, 0, 0, 0, 0, 0, 0, 28, 25, 26, 27, 0, 0, 10, 9, 11, 12, 0, 15, 16, 17, 18, 0, 20, 22, 23, 21 } 110 | local HEX64, XOR64A5, lanes_index_base -- defined only for branches that internally use 64-bit integers: "INT64" and "FFI" 111 | local common_W = {} -- temporary table shared between all calculations (to avoid creating new temporary table every time) 112 | local K_lo_modulo, hi_factor, hi_factor_keccak = 4294967296, 0, 0 113 | 114 | local TWO_POW_NEG_56 = 2 ^ -56 115 | local TWO_POW_NEG_17 = 2 ^ -17 116 | 117 | local TWO_POW_2 = 2 ^ 2 118 | local TWO_POW_3 = 2 ^ 3 119 | local TWO_POW_4 = 2 ^ 4 120 | local TWO_POW_5 = 2 ^ 5 121 | local TWO_POW_6 = 2 ^ 6 122 | local TWO_POW_7 = 2 ^ 7 123 | local TWO_POW_8 = 2 ^ 8 124 | local TWO_POW_9 = 2 ^ 9 125 | local TWO_POW_10 = 2 ^ 10 126 | local TWO_POW_11 = 2 ^ 11 127 | local TWO_POW_12 = 2 ^ 12 128 | local TWO_POW_13 = 2 ^ 13 129 | local TWO_POW_14 = 2 ^ 14 130 | local TWO_POW_15 = 2 ^ 15 131 | local TWO_POW_16 = 2 ^ 16 132 | local TWO_POW_17 = 2 ^ 17 133 | local TWO_POW_18 = 2 ^ 18 134 | local TWO_POW_19 = 2 ^ 19 135 | local TWO_POW_20 = 2 ^ 20 136 | local TWO_POW_21 = 2 ^ 21 137 | local TWO_POW_22 = 2 ^ 22 138 | local TWO_POW_23 = 2 ^ 23 139 | local TWO_POW_24 = 2 ^ 24 140 | local TWO_POW_25 = 2 ^ 25 141 | local TWO_POW_26 = 2 ^ 26 142 | local TWO_POW_27 = 2 ^ 27 143 | local TWO_POW_28 = 2 ^ 28 144 | local TWO_POW_29 = 2 ^ 29 145 | local TWO_POW_30 = 2 ^ 30 146 | local TWO_POW_31 = 2 ^ 31 147 | local TWO_POW_32 = 2 ^ 32 148 | local TWO_POW_40 = 2 ^ 40 149 | 150 | local TWO56_POW_7 = 256 ^ 7 151 | 152 | -- Implementation for Lua 5.1/5.2 (with or without bitwise library available) 153 | local function sha256_feed_64(H, str, offs, size) 154 | -- offs >= 0, size >= 0, size is multiple of 64 155 | local W, K = common_W, sha2_K_hi 156 | local h1, h2, h3, h4, h5, h6, h7, h8 = H[1], H[2], H[3], H[4], H[5], H[6], H[7], H[8] 157 | for pos = offs, offs + size - 1, 64 do 158 | for j = 1, 16 do 159 | pos = pos + 4 160 | local a, b, c, d = string.byte(str, pos - 3, pos) 161 | W[j] = ((a * 256 + b) * 256 + c) * 256 + d 162 | end 163 | 164 | for j = 17, 64 do 165 | local a, b = W[j - 15], W[j - 2] 166 | W[j] = bit32_bxor(bit32_rrotate(a, 7), bit32_lrotate(a, 14), bit32_rshift(a, 3)) 167 | + bit32_bxor(bit32_lrotate(b, 15), bit32_lrotate(b, 13), bit32_rshift(b, 10)) 168 | + W[j - 7] 169 | + W[j - 16] 170 | end 171 | 172 | local a, b, c, d, e, f, g, h = h1, h2, h3, h4, h5, h6, h7, h8 173 | for j = 1, 64 do 174 | local z = bit32_bxor(bit32_rrotate(e, 6), bit32_rrotate(e, 11), bit32_lrotate(e, 7)) 175 | + bit32_band(e, f) 176 | + bit32_band(-1 - e, g) 177 | + h 178 | + K[j] 179 | + W[j] 180 | h = g 181 | g = f 182 | f = e 183 | e = z + d 184 | d = c 185 | c = b 186 | b = a 187 | a = z 188 | + bit32_band(d, c) 189 | + bit32_band(a, bit32_bxor(d, c)) 190 | + bit32_bxor(bit32_rrotate(a, 2), bit32_rrotate(a, 13), bit32_lrotate(a, 10)) 191 | end 192 | 193 | h1, h2, h3, h4 = (a + h1) % 4294967296, (b + h2) % 4294967296, (c + h3) % 4294967296, (d + h4) % 4294967296 194 | h5, h6, h7, h8 = (e + h5) % 4294967296, (f + h6) % 4294967296, (g + h7) % 4294967296, (h + h8) % 4294967296 195 | end 196 | 197 | H[1], H[2], H[3], H[4], H[5], H[6], H[7], H[8] = h1, h2, h3, h4, h5, h6, h7, h8 198 | end 199 | 200 | local function sha512_feed_128(H_lo, H_hi, str, offs, size) 201 | -- offs >= 0, size >= 0, size is multiple of 128 202 | -- W1_hi, W1_lo, W2_hi, W2_lo, ... Wk_hi = W[2*k-1], Wk_lo = W[2*k] 203 | local W, K_lo, K_hi = common_W, sha2_K_lo, sha2_K_hi 204 | local h1_lo, h2_lo, h3_lo, h4_lo, h5_lo, h6_lo, h7_lo, h8_lo = 205 | H_lo[1], H_lo[2], H_lo[3], H_lo[4], H_lo[5], H_lo[6], H_lo[7], H_lo[8] 206 | local h1_hi, h2_hi, h3_hi, h4_hi, h5_hi, h6_hi, h7_hi, h8_hi = 207 | H_hi[1], H_hi[2], H_hi[3], H_hi[4], H_hi[5], H_hi[6], H_hi[7], H_hi[8] 208 | for pos = offs, offs + size - 1, 128 do 209 | for j = 1, 16 * 2 do 210 | pos = pos + 4 211 | local a, b, c, d = string.byte(str, pos - 3, pos) 212 | W[j] = ((a * 256 + b) * 256 + c) * 256 + d 213 | end 214 | 215 | for jj = 17 * 2, 80 * 2, 2 do 216 | local a_lo, a_hi, b_lo, b_hi = W[jj - 30], W[jj - 31], W[jj - 4], W[jj - 5] 217 | local tmp1 = bit32_bxor( 218 | bit32_rshift(a_lo, 1) + bit32_lshift(a_hi, 31), 219 | bit32_rshift(a_lo, 8) + bit32_lshift(a_hi, 24), 220 | bit32_rshift(a_lo, 7) + bit32_lshift(a_hi, 25) 221 | ) % 4294967296 + bit32_bxor( 222 | bit32_rshift(b_lo, 19) + bit32_lshift(b_hi, 13), 223 | bit32_lshift(b_lo, 3) + bit32_rshift(b_hi, 29), 224 | bit32_rshift(b_lo, 6) + bit32_lshift(b_hi, 26) 225 | ) % 4294967296 + W[jj - 14] + W[jj - 32] 226 | 227 | local tmp2 = tmp1 % 4294967296 228 | W[jj - 1] = bit32_bxor( 229 | bit32_rshift(a_hi, 1) + bit32_lshift(a_lo, 31), 230 | bit32_rshift(a_hi, 8) + bit32_lshift(a_lo, 24), 231 | bit32_rshift(a_hi, 7) 232 | ) + bit32_bxor( 233 | bit32_rshift(b_hi, 19) + bit32_lshift(b_lo, 13), 234 | bit32_lshift(b_hi, 3) + bit32_rshift(b_lo, 29), 235 | bit32_rshift(b_hi, 6) 236 | ) + W[jj - 15] + W[jj - 33] + (tmp1 - tmp2) / 4294967296 237 | 238 | W[jj] = tmp2 239 | end 240 | 241 | local a_lo, b_lo, c_lo, d_lo, e_lo, f_lo, g_lo, h_lo = h1_lo, h2_lo, h3_lo, h4_lo, h5_lo, h6_lo, h7_lo, h8_lo 242 | local a_hi, b_hi, c_hi, d_hi, e_hi, f_hi, g_hi, h_hi = h1_hi, h2_hi, h3_hi, h4_hi, h5_hi, h6_hi, h7_hi, h8_hi 243 | for j = 1, 80 do 244 | local jj = 2 * j 245 | local tmp1 = bit32_bxor( 246 | bit32_rshift(e_lo, 14) + bit32_lshift(e_hi, 18), 247 | bit32_rshift(e_lo, 18) + bit32_lshift(e_hi, 14), 248 | bit32_lshift(e_lo, 23) + bit32_rshift(e_hi, 9) 249 | ) % 4294967296 + (bit32_band(e_lo, f_lo) + bit32_band(-1 - e_lo, g_lo)) % 4294967296 + h_lo + K_lo[j] + W[jj] 250 | 251 | local z_lo = tmp1 % 4294967296 252 | local z_hi = bit32_bxor( 253 | bit32_rshift(e_hi, 14) + bit32_lshift(e_lo, 18), 254 | bit32_rshift(e_hi, 18) + bit32_lshift(e_lo, 14), 255 | bit32_lshift(e_hi, 23) + bit32_rshift(e_lo, 9) 256 | ) + bit32_band(e_hi, f_hi) + bit32_band(-1 - e_hi, g_hi) + h_hi + K_hi[j] + W[jj - 1] + (tmp1 - z_lo) / 4294967296 257 | 258 | h_lo = g_lo 259 | h_hi = g_hi 260 | g_lo = f_lo 261 | g_hi = f_hi 262 | f_lo = e_lo 263 | f_hi = e_hi 264 | tmp1 = z_lo + d_lo 265 | e_lo = tmp1 % 4294967296 266 | e_hi = z_hi + d_hi + (tmp1 - e_lo) / 4294967296 267 | d_lo = c_lo 268 | d_hi = c_hi 269 | c_lo = b_lo 270 | c_hi = b_hi 271 | b_lo = a_lo 272 | b_hi = a_hi 273 | tmp1 = z_lo 274 | + (bit32_band(d_lo, c_lo) + bit32_band(b_lo, bit32_bxor(d_lo, c_lo))) % 4294967296 275 | + bit32_bxor( 276 | bit32_rshift(b_lo, 28) + bit32_lshift(b_hi, 4), 277 | bit32_lshift(b_lo, 30) + bit32_rshift(b_hi, 2), 278 | bit32_lshift(b_lo, 25) + bit32_rshift(b_hi, 7) 279 | ) 280 | % 4294967296 281 | a_lo = tmp1 % 4294967296 282 | a_hi = z_hi 283 | + (bit32_band(d_hi, c_hi) + bit32_band(b_hi, bit32_bxor(d_hi, c_hi))) 284 | + bit32_bxor( 285 | bit32_rshift(b_hi, 28) + bit32_lshift(b_lo, 4), 286 | bit32_lshift(b_hi, 30) + bit32_rshift(b_lo, 2), 287 | bit32_lshift(b_hi, 25) + bit32_rshift(b_lo, 7) 288 | ) 289 | + (tmp1 - a_lo) / 4294967296 290 | end 291 | 292 | a_lo = h1_lo + a_lo 293 | h1_lo = a_lo % 4294967296 294 | h1_hi = (h1_hi + a_hi + (a_lo - h1_lo) / 4294967296) % 4294967296 295 | a_lo = h2_lo + b_lo 296 | h2_lo = a_lo % 4294967296 297 | h2_hi = (h2_hi + b_hi + (a_lo - h2_lo) / 4294967296) % 4294967296 298 | a_lo = h3_lo + c_lo 299 | h3_lo = a_lo % 4294967296 300 | h3_hi = (h3_hi + c_hi + (a_lo - h3_lo) / 4294967296) % 4294967296 301 | a_lo = h4_lo + d_lo 302 | h4_lo = a_lo % 4294967296 303 | h4_hi = (h4_hi + d_hi + (a_lo - h4_lo) / 4294967296) % 4294967296 304 | a_lo = h5_lo + e_lo 305 | h5_lo = a_lo % 4294967296 306 | h5_hi = (h5_hi + e_hi + (a_lo - h5_lo) / 4294967296) % 4294967296 307 | a_lo = h6_lo + f_lo 308 | h6_lo = a_lo % 4294967296 309 | h6_hi = (h6_hi + f_hi + (a_lo - h6_lo) / 4294967296) % 4294967296 310 | a_lo = h7_lo + g_lo 311 | h7_lo = a_lo % 4294967296 312 | h7_hi = (h7_hi + g_hi + (a_lo - h7_lo) / 4294967296) % 4294967296 313 | a_lo = h8_lo + h_lo 314 | h8_lo = a_lo % 4294967296 315 | h8_hi = (h8_hi + h_hi + (a_lo - h8_lo) / 4294967296) % 4294967296 316 | end 317 | 318 | H_lo[1], H_lo[2], H_lo[3], H_lo[4], H_lo[5], H_lo[6], H_lo[7], H_lo[8] = 319 | h1_lo, h2_lo, h3_lo, h4_lo, h5_lo, h6_lo, h7_lo, h8_lo 320 | H_hi[1], H_hi[2], H_hi[3], H_hi[4], H_hi[5], H_hi[6], H_hi[7], H_hi[8] = 321 | h1_hi, h2_hi, h3_hi, h4_hi, h5_hi, h6_hi, h7_hi, h8_hi 322 | end 323 | 324 | local function md5_feed_64(H, str, offs, size) 325 | -- offs >= 0, size >= 0, size is multiple of 64 326 | local W, K, md5_next_shift = common_W, md5_K, md5_next_shift 327 | local h1, h2, h3, h4 = H[1], H[2], H[3], H[4] 328 | for pos = offs, offs + size - 1, 64 do 329 | for j = 1, 16 do 330 | pos = pos + 4 331 | local a, b, c, d = string.byte(str, pos - 3, pos) 332 | W[j] = ((d * 256 + c) * 256 + b) * 256 + a 333 | end 334 | 335 | local a, b, c, d = h1, h2, h3, h4 336 | local s = 32 - 7 337 | for j = 1, 16 do 338 | local F = bit32_rrotate(bit32_band(b, c) + bit32_band(-1 - b, d) + a + K[j] + W[j], s) + b 339 | s = md5_next_shift[s] 340 | a = d 341 | d = c 342 | c = b 343 | b = F 344 | end 345 | 346 | s = 32 - 5 347 | for j = 17, 32 do 348 | local F = bit32_rrotate(bit32_band(d, b) + bit32_band(-1 - d, c) + a + K[j] + W[(5 * j - 4) % 16 + 1], s) 349 | + b 350 | s = md5_next_shift[s] 351 | a = d 352 | d = c 353 | c = b 354 | b = F 355 | end 356 | 357 | s = 32 - 4 358 | for j = 33, 48 do 359 | local F = bit32_rrotate(bit32_bxor(bit32_bxor(b, c), d) + a + K[j] + W[(3 * j + 2) % 16 + 1], s) + b 360 | s = md5_next_shift[s] 361 | a = d 362 | d = c 363 | c = b 364 | b = F 365 | end 366 | 367 | s = 32 - 6 368 | for j = 49, 64 do 369 | local F = bit32_rrotate(bit32_bxor(c, bit32_bor(b, -1 - d)) + a + K[j] + W[(j * 7 - 7) % 16 + 1], s) + b 370 | s = md5_next_shift[s] 371 | a = d 372 | d = c 373 | c = b 374 | b = F 375 | end 376 | 377 | h1 = (a + h1) % 4294967296 378 | h2 = (b + h2) % 4294967296 379 | h3 = (c + h3) % 4294967296 380 | h4 = (d + h4) % 4294967296 381 | end 382 | 383 | H[1], H[2], H[3], H[4] = h1, h2, h3, h4 384 | end 385 | 386 | local function sha1_feed_64(H, str, offs, size) 387 | -- offs >= 0, size >= 0, size is multiple of 64 388 | local W = common_W 389 | local h1, h2, h3, h4, h5 = H[1], H[2], H[3], H[4], H[5] 390 | for pos = offs, offs + size - 1, 64 do 391 | for j = 1, 16 do 392 | pos = pos + 4 393 | local a, b, c, d = string.byte(str, pos - 3, pos) 394 | W[j] = ((a * 256 + b) * 256 + c) * 256 + d 395 | end 396 | 397 | for j = 17, 80 do 398 | W[j] = bit32_lrotate(bit32_bxor(W[j - 3], W[j - 8], W[j - 14], W[j - 16]), 1) 399 | end 400 | 401 | local a, b, c, d, e = h1, h2, h3, h4, h5 402 | for j = 1, 20 do 403 | local z = bit32_lrotate(a, 5) + bit32_band(b, c) + bit32_band(-1 - b, d) + 0x5A827999 + W[j] + e -- constant = math.floor(TWO_POW_30 * sqrt(2)) 404 | e = d 405 | d = c 406 | c = bit32_rrotate(b, 2) 407 | b = a 408 | a = z 409 | end 410 | 411 | for j = 21, 40 do 412 | local z = bit32_lrotate(a, 5) + bit32_bxor(b, c, d) + 0x6ED9EBA1 + W[j] + e -- TWO_POW_30 * sqrt(3) 413 | e = d 414 | d = c 415 | c = bit32_rrotate(b, 2) 416 | b = a 417 | a = z 418 | end 419 | 420 | for j = 41, 60 do 421 | local z = bit32_lrotate(a, 5) + bit32_band(d, c) + bit32_band(b, bit32_bxor(d, c)) + 0x8F1BBCDC + W[j] + e -- TWO_POW_30 * sqrt(5) 422 | e = d 423 | d = c 424 | c = bit32_rrotate(b, 2) 425 | b = a 426 | a = z 427 | end 428 | 429 | for j = 61, 80 do 430 | local z = bit32_lrotate(a, 5) + bit32_bxor(b, c, d) + 0xCA62C1D6 + W[j] + e -- TWO_POW_30 * sqrt(10) 431 | e = d 432 | d = c 433 | c = bit32_rrotate(b, 2) 434 | b = a 435 | a = z 436 | end 437 | 438 | h1 = (a + h1) % 4294967296 439 | h2 = (b + h2) % 4294967296 440 | h3 = (c + h3) % 4294967296 441 | h4 = (d + h4) % 4294967296 442 | h5 = (e + h5) % 4294967296 443 | end 444 | 445 | H[1], H[2], H[3], H[4], H[5] = h1, h2, h3, h4, h5 446 | end 447 | 448 | local function keccak_feed(lanes_lo, lanes_hi, str, offs, size, block_size_in_bytes) 449 | -- This is an example of a Lua function having 79 local variables :-) 450 | -- offs >= 0, size >= 0, size is multiple of block_size_in_bytes, block_size_in_bytes is positive multiple of 8 451 | local RC_lo, RC_hi = sha3_RC_lo, sha3_RC_hi 452 | local qwords_qty = block_size_in_bytes / 8 453 | for pos = offs, offs + size - 1, block_size_in_bytes do 454 | for j = 1, qwords_qty do 455 | local a, b, c, d = string.byte(str, pos + 1, pos + 4) 456 | lanes_lo[j] = bit32_bxor(lanes_lo[j], ((d * 256 + c) * 256 + b) * 256 + a) 457 | pos = pos + 8 458 | a, b, c, d = string.byte(str, pos - 3, pos) 459 | lanes_hi[j] = bit32_bxor(lanes_hi[j], ((d * 256 + c) * 256 + b) * 256 + a) 460 | end 461 | 462 | local L01_lo, L01_hi, L02_lo, L02_hi, L03_lo, L03_hi, L04_lo, L04_hi, L05_lo, L05_hi, L06_lo, L06_hi, L07_lo, L07_hi, L08_lo, L08_hi, L09_lo, L09_hi, L10_lo, L10_hi, L11_lo, L11_hi, L12_lo, L12_hi, L13_lo, L13_hi, L14_lo, L14_hi, L15_lo, L15_hi, L16_lo, L16_hi, L17_lo, L17_hi, L18_lo, L18_hi, L19_lo, L19_hi, L20_lo, L20_hi, L21_lo, L21_hi, L22_lo, L22_hi, L23_lo, L23_hi, L24_lo, L24_hi, L25_lo, L25_hi = 463 | lanes_lo[1], 464 | lanes_hi[1], 465 | lanes_lo[2], 466 | lanes_hi[2], 467 | lanes_lo[3], 468 | lanes_hi[3], 469 | lanes_lo[4], 470 | lanes_hi[4], 471 | lanes_lo[5], 472 | lanes_hi[5], 473 | lanes_lo[6], 474 | lanes_hi[6], 475 | lanes_lo[7], 476 | lanes_hi[7], 477 | lanes_lo[8], 478 | lanes_hi[8], 479 | lanes_lo[9], 480 | lanes_hi[9], 481 | lanes_lo[10], 482 | lanes_hi[10], 483 | lanes_lo[11], 484 | lanes_hi[11], 485 | lanes_lo[12], 486 | lanes_hi[12], 487 | lanes_lo[13], 488 | lanes_hi[13], 489 | lanes_lo[14], 490 | lanes_hi[14], 491 | lanes_lo[15], 492 | lanes_hi[15], 493 | lanes_lo[16], 494 | lanes_hi[16], 495 | lanes_lo[17], 496 | lanes_hi[17], 497 | lanes_lo[18], 498 | lanes_hi[18], 499 | lanes_lo[19], 500 | lanes_hi[19], 501 | lanes_lo[20], 502 | lanes_hi[20], 503 | lanes_lo[21], 504 | lanes_hi[21], 505 | lanes_lo[22], 506 | lanes_hi[22], 507 | lanes_lo[23], 508 | lanes_hi[23], 509 | lanes_lo[24], 510 | lanes_hi[24], 511 | lanes_lo[25], 512 | lanes_hi[25] 513 | 514 | for round_idx = 1, 24 do 515 | local C1_lo = bit32_bxor(L01_lo, L06_lo, L11_lo, L16_lo, L21_lo) 516 | local C1_hi = bit32_bxor(L01_hi, L06_hi, L11_hi, L16_hi, L21_hi) 517 | local C2_lo = bit32_bxor(L02_lo, L07_lo, L12_lo, L17_lo, L22_lo) 518 | local C2_hi = bit32_bxor(L02_hi, L07_hi, L12_hi, L17_hi, L22_hi) 519 | local C3_lo = bit32_bxor(L03_lo, L08_lo, L13_lo, L18_lo, L23_lo) 520 | local C3_hi = bit32_bxor(L03_hi, L08_hi, L13_hi, L18_hi, L23_hi) 521 | local C4_lo = bit32_bxor(L04_lo, L09_lo, L14_lo, L19_lo, L24_lo) 522 | local C4_hi = bit32_bxor(L04_hi, L09_hi, L14_hi, L19_hi, L24_hi) 523 | local C5_lo = bit32_bxor(L05_lo, L10_lo, L15_lo, L20_lo, L25_lo) 524 | local C5_hi = bit32_bxor(L05_hi, L10_hi, L15_hi, L20_hi, L25_hi) 525 | 526 | local D_lo = bit32_bxor(C1_lo, C3_lo * 2 + (C3_hi % TWO_POW_32 - C3_hi % TWO_POW_31) / TWO_POW_31) 527 | local D_hi = bit32_bxor(C1_hi, C3_hi * 2 + (C3_lo % TWO_POW_32 - C3_lo % TWO_POW_31) / TWO_POW_31) 528 | 529 | local T0_lo = bit32_bxor(D_lo, L02_lo) 530 | local T0_hi = bit32_bxor(D_hi, L02_hi) 531 | local T1_lo = bit32_bxor(D_lo, L07_lo) 532 | local T1_hi = bit32_bxor(D_hi, L07_hi) 533 | local T2_lo = bit32_bxor(D_lo, L12_lo) 534 | local T2_hi = bit32_bxor(D_hi, L12_hi) 535 | local T3_lo = bit32_bxor(D_lo, L17_lo) 536 | local T3_hi = bit32_bxor(D_hi, L17_hi) 537 | local T4_lo = bit32_bxor(D_lo, L22_lo) 538 | local T4_hi = bit32_bxor(D_hi, L22_hi) 539 | 540 | L02_lo = (T1_lo % TWO_POW_32 - T1_lo % TWO_POW_20) / TWO_POW_20 + T1_hi * TWO_POW_12 541 | L02_hi = (T1_hi % TWO_POW_32 - T1_hi % TWO_POW_20) / TWO_POW_20 + T1_lo * TWO_POW_12 542 | L07_lo = (T3_lo % TWO_POW_32 - T3_lo % TWO_POW_19) / TWO_POW_19 + T3_hi * TWO_POW_13 543 | L07_hi = (T3_hi % TWO_POW_32 - T3_hi % TWO_POW_19) / TWO_POW_19 + T3_lo * TWO_POW_13 544 | L12_lo = T0_lo * 2 + (T0_hi % TWO_POW_32 - T0_hi % TWO_POW_31) / TWO_POW_31 545 | L12_hi = T0_hi * 2 + (T0_lo % TWO_POW_32 - T0_lo % TWO_POW_31) / TWO_POW_31 546 | L17_lo = T2_lo * TWO_POW_10 + (T2_hi % TWO_POW_32 - T2_hi % TWO_POW_22) / TWO_POW_22 547 | L17_hi = T2_hi * TWO_POW_10 + (T2_lo % TWO_POW_32 - T2_lo % TWO_POW_22) / TWO_POW_22 548 | L22_lo = T4_lo * TWO_POW_2 + (T4_hi % TWO_POW_32 - T4_hi % TWO_POW_30) / TWO_POW_30 549 | L22_hi = T4_hi * TWO_POW_2 + (T4_lo % TWO_POW_32 - T4_lo % TWO_POW_30) / TWO_POW_30 550 | 551 | D_lo = bit32_bxor(C2_lo, C4_lo * 2 + (C4_hi % TWO_POW_32 - C4_hi % TWO_POW_31) / TWO_POW_31) 552 | D_hi = bit32_bxor(C2_hi, C4_hi * 2 + (C4_lo % TWO_POW_32 - C4_lo % TWO_POW_31) / TWO_POW_31) 553 | 554 | T0_lo = bit32_bxor(D_lo, L03_lo) 555 | T0_hi = bit32_bxor(D_hi, L03_hi) 556 | T1_lo = bit32_bxor(D_lo, L08_lo) 557 | T1_hi = bit32_bxor(D_hi, L08_hi) 558 | T2_lo = bit32_bxor(D_lo, L13_lo) 559 | T2_hi = bit32_bxor(D_hi, L13_hi) 560 | T3_lo = bit32_bxor(D_lo, L18_lo) 561 | T3_hi = bit32_bxor(D_hi, L18_hi) 562 | T4_lo = bit32_bxor(D_lo, L23_lo) 563 | T4_hi = bit32_bxor(D_hi, L23_hi) 564 | 565 | L03_lo = (T2_lo % TWO_POW_32 - T2_lo % TWO_POW_21) / TWO_POW_21 + T2_hi * TWO_POW_11 566 | L03_hi = (T2_hi % TWO_POW_32 - T2_hi % TWO_POW_21) / TWO_POW_21 + T2_lo * TWO_POW_11 567 | L08_lo = (T4_lo % TWO_POW_32 - T4_lo % TWO_POW_3) / TWO_POW_3 + T4_hi * TWO_POW_29 % TWO_POW_32 568 | L08_hi = (T4_hi % TWO_POW_32 - T4_hi % TWO_POW_3) / TWO_POW_3 + T4_lo * TWO_POW_29 % TWO_POW_32 569 | L13_lo = T1_lo * TWO_POW_6 + (T1_hi % TWO_POW_32 - T1_hi % TWO_POW_26) / TWO_POW_26 570 | L13_hi = T1_hi * TWO_POW_6 + (T1_lo % TWO_POW_32 - T1_lo % TWO_POW_26) / TWO_POW_26 571 | L18_lo = T3_lo * TWO_POW_15 + (T3_hi % TWO_POW_32 - T3_hi % TWO_POW_17) / TWO_POW_17 572 | L18_hi = T3_hi * TWO_POW_15 + (T3_lo % TWO_POW_32 - T3_lo % TWO_POW_17) / TWO_POW_17 573 | L23_lo = (T0_lo % TWO_POW_32 - T0_lo % TWO_POW_2) / TWO_POW_2 + T0_hi * TWO_POW_30 % TWO_POW_32 574 | L23_hi = (T0_hi % TWO_POW_32 - T0_hi % TWO_POW_2) / TWO_POW_2 + T0_lo * TWO_POW_30 % TWO_POW_32 575 | 576 | D_lo = bit32_bxor(C3_lo, C5_lo * 2 + (C5_hi % TWO_POW_32 - C5_hi % TWO_POW_31) / TWO_POW_31) 577 | D_hi = bit32_bxor(C3_hi, C5_hi * 2 + (C5_lo % TWO_POW_32 - C5_lo % TWO_POW_31) / TWO_POW_31) 578 | 579 | T0_lo = bit32_bxor(D_lo, L04_lo) 580 | T0_hi = bit32_bxor(D_hi, L04_hi) 581 | T1_lo = bit32_bxor(D_lo, L09_lo) 582 | T1_hi = bit32_bxor(D_hi, L09_hi) 583 | T2_lo = bit32_bxor(D_lo, L14_lo) 584 | T2_hi = bit32_bxor(D_hi, L14_hi) 585 | T3_lo = bit32_bxor(D_lo, L19_lo) 586 | T3_hi = bit32_bxor(D_hi, L19_hi) 587 | T4_lo = bit32_bxor(D_lo, L24_lo) 588 | T4_hi = bit32_bxor(D_hi, L24_hi) 589 | 590 | L04_lo = T3_lo * TWO_POW_21 % TWO_POW_32 + (T3_hi % TWO_POW_32 - T3_hi % TWO_POW_11) / TWO_POW_11 591 | L04_hi = T3_hi * TWO_POW_21 % TWO_POW_32 + (T3_lo % TWO_POW_32 - T3_lo % TWO_POW_11) / TWO_POW_11 592 | L09_lo = T0_lo * TWO_POW_28 % TWO_POW_32 + (T0_hi % TWO_POW_32 - T0_hi % TWO_POW_4) / TWO_POW_4 593 | L09_hi = T0_hi * TWO_POW_28 % TWO_POW_32 + (T0_lo % TWO_POW_32 - T0_lo % TWO_POW_4) / TWO_POW_4 594 | L14_lo = T2_lo * TWO_POW_25 % TWO_POW_32 + (T2_hi % TWO_POW_32 - T2_hi % TWO_POW_7) / TWO_POW_7 595 | L14_hi = T2_hi * TWO_POW_25 % TWO_POW_32 + (T2_lo % TWO_POW_32 - T2_lo % TWO_POW_7) / TWO_POW_7 596 | L19_lo = (T4_lo % TWO_POW_32 - T4_lo % TWO_POW_8) / TWO_POW_8 + T4_hi * TWO_POW_24 % TWO_POW_32 597 | L19_hi = (T4_hi % TWO_POW_32 - T4_hi % TWO_POW_8) / TWO_POW_8 + T4_lo * TWO_POW_24 % TWO_POW_32 598 | L24_lo = (T1_lo % TWO_POW_32 - T1_lo % TWO_POW_9) / TWO_POW_9 + T1_hi * TWO_POW_23 % TWO_POW_32 599 | L24_hi = (T1_hi % TWO_POW_32 - T1_hi % TWO_POW_9) / TWO_POW_9 + T1_lo * TWO_POW_23 % TWO_POW_32 600 | 601 | D_lo = bit32_bxor(C4_lo, C1_lo * 2 + (C1_hi % TWO_POW_32 - C1_hi % TWO_POW_31) / TWO_POW_31) 602 | D_hi = bit32_bxor(C4_hi, C1_hi * 2 + (C1_lo % TWO_POW_32 - C1_lo % TWO_POW_31) / TWO_POW_31) 603 | 604 | T0_lo = bit32_bxor(D_lo, L05_lo) 605 | T0_hi = bit32_bxor(D_hi, L05_hi) 606 | T1_lo = bit32_bxor(D_lo, L10_lo) 607 | T1_hi = bit32_bxor(D_hi, L10_hi) 608 | T2_lo = bit32_bxor(D_lo, L15_lo) 609 | T2_hi = bit32_bxor(D_hi, L15_hi) 610 | T3_lo = bit32_bxor(D_lo, L20_lo) 611 | T3_hi = bit32_bxor(D_hi, L20_hi) 612 | T4_lo = bit32_bxor(D_lo, L25_lo) 613 | T4_hi = bit32_bxor(D_hi, L25_hi) 614 | 615 | L05_lo = T4_lo * TWO_POW_14 + (T4_hi % TWO_POW_32 - T4_hi % TWO_POW_18) / TWO_POW_18 616 | L05_hi = T4_hi * TWO_POW_14 + (T4_lo % TWO_POW_32 - T4_lo % TWO_POW_18) / TWO_POW_18 617 | L10_lo = T1_lo * TWO_POW_20 % TWO_POW_32 + (T1_hi % TWO_POW_32 - T1_hi % TWO_POW_12) / TWO_POW_12 618 | L10_hi = T1_hi * TWO_POW_20 % TWO_POW_32 + (T1_lo % TWO_POW_32 - T1_lo % TWO_POW_12) / TWO_POW_12 619 | L15_lo = T3_lo * TWO_POW_8 + (T3_hi % TWO_POW_32 - T3_hi % TWO_POW_24) / TWO_POW_24 620 | L15_hi = T3_hi * TWO_POW_8 + (T3_lo % TWO_POW_32 - T3_lo % TWO_POW_24) / TWO_POW_24 621 | L20_lo = T0_lo * TWO_POW_27 % TWO_POW_32 + (T0_hi % TWO_POW_32 - T0_hi % TWO_POW_5) / TWO_POW_5 622 | L20_hi = T0_hi * TWO_POW_27 % TWO_POW_32 + (T0_lo % TWO_POW_32 - T0_lo % TWO_POW_5) / TWO_POW_5 623 | L25_lo = (T2_lo % TWO_POW_32 - T2_lo % TWO_POW_25) / TWO_POW_25 + T2_hi * TWO_POW_7 624 | L25_hi = (T2_hi % TWO_POW_32 - T2_hi % TWO_POW_25) / TWO_POW_25 + T2_lo * TWO_POW_7 625 | 626 | D_lo = bit32_bxor(C5_lo, C2_lo * 2 + (C2_hi % TWO_POW_32 - C2_hi % TWO_POW_31) / TWO_POW_31) 627 | D_hi = bit32_bxor(C5_hi, C2_hi * 2 + (C2_lo % TWO_POW_32 - C2_lo % TWO_POW_31) / TWO_POW_31) 628 | 629 | T1_lo = bit32_bxor(D_lo, L06_lo) 630 | T1_hi = bit32_bxor(D_hi, L06_hi) 631 | T2_lo = bit32_bxor(D_lo, L11_lo) 632 | T2_hi = bit32_bxor(D_hi, L11_hi) 633 | T3_lo = bit32_bxor(D_lo, L16_lo) 634 | T3_hi = bit32_bxor(D_hi, L16_hi) 635 | T4_lo = bit32_bxor(D_lo, L21_lo) 636 | T4_hi = bit32_bxor(D_hi, L21_hi) 637 | 638 | L06_lo = T2_lo * TWO_POW_3 + (T2_hi % TWO_POW_32 - T2_hi % TWO_POW_29) / TWO_POW_29 639 | L06_hi = T2_hi * TWO_POW_3 + (T2_lo % TWO_POW_32 - T2_lo % TWO_POW_29) / TWO_POW_29 640 | L11_lo = T4_lo * TWO_POW_18 + (T4_hi % TWO_POW_32 - T4_hi % TWO_POW_14) / TWO_POW_14 641 | L11_hi = T4_hi * TWO_POW_18 + (T4_lo % TWO_POW_32 - T4_lo % TWO_POW_14) / TWO_POW_14 642 | L16_lo = (T1_lo % TWO_POW_32 - T1_lo % TWO_POW_28) / TWO_POW_28 + T1_hi * TWO_POW_4 643 | L16_hi = (T1_hi % TWO_POW_32 - T1_hi % TWO_POW_28) / TWO_POW_28 + T1_lo * TWO_POW_4 644 | L21_lo = (T3_lo % TWO_POW_32 - T3_lo % TWO_POW_23) / TWO_POW_23 + T3_hi * TWO_POW_9 645 | L21_hi = (T3_hi % TWO_POW_32 - T3_hi % TWO_POW_23) / TWO_POW_23 + T3_lo * TWO_POW_9 646 | 647 | L01_lo = bit32_bxor(D_lo, L01_lo) 648 | L01_hi = bit32_bxor(D_hi, L01_hi) 649 | L01_lo, L02_lo, L03_lo, L04_lo, L05_lo = 650 | bit32_bxor(L01_lo, bit32_band(-1 - L02_lo, L03_lo)), 651 | bit32_bxor(L02_lo, bit32_band(-1 - L03_lo, L04_lo)), 652 | bit32_bxor(L03_lo, bit32_band(-1 - L04_lo, L05_lo)), 653 | bit32_bxor(L04_lo, bit32_band(-1 - L05_lo, L01_lo)), 654 | bit32_bxor(L05_lo, bit32_band(-1 - L01_lo, L02_lo)) 655 | L01_hi, L02_hi, L03_hi, L04_hi, L05_hi = 656 | bit32_bxor(L01_hi, bit32_band(-1 - L02_hi, L03_hi)), 657 | bit32_bxor(L02_hi, bit32_band(-1 - L03_hi, L04_hi)), 658 | bit32_bxor(L03_hi, bit32_band(-1 - L04_hi, L05_hi)), 659 | bit32_bxor(L04_hi, bit32_band(-1 - L05_hi, L01_hi)), 660 | bit32_bxor(L05_hi, bit32_band(-1 - L01_hi, L02_hi)) 661 | L06_lo, L07_lo, L08_lo, L09_lo, L10_lo = 662 | bit32_bxor(L09_lo, bit32_band(-1 - L10_lo, L06_lo)), 663 | bit32_bxor(L10_lo, bit32_band(-1 - L06_lo, L07_lo)), 664 | bit32_bxor(L06_lo, bit32_band(-1 - L07_lo, L08_lo)), 665 | bit32_bxor(L07_lo, bit32_band(-1 - L08_lo, L09_lo)), 666 | bit32_bxor(L08_lo, bit32_band(-1 - L09_lo, L10_lo)) 667 | L06_hi, L07_hi, L08_hi, L09_hi, L10_hi = 668 | bit32_bxor(L09_hi, bit32_band(-1 - L10_hi, L06_hi)), 669 | bit32_bxor(L10_hi, bit32_band(-1 - L06_hi, L07_hi)), 670 | bit32_bxor(L06_hi, bit32_band(-1 - L07_hi, L08_hi)), 671 | bit32_bxor(L07_hi, bit32_band(-1 - L08_hi, L09_hi)), 672 | bit32_bxor(L08_hi, bit32_band(-1 - L09_hi, L10_hi)) 673 | L11_lo, L12_lo, L13_lo, L14_lo, L15_lo = 674 | bit32_bxor(L12_lo, bit32_band(-1 - L13_lo, L14_lo)), 675 | bit32_bxor(L13_lo, bit32_band(-1 - L14_lo, L15_lo)), 676 | bit32_bxor(L14_lo, bit32_band(-1 - L15_lo, L11_lo)), 677 | bit32_bxor(L15_lo, bit32_band(-1 - L11_lo, L12_lo)), 678 | bit32_bxor(L11_lo, bit32_band(-1 - L12_lo, L13_lo)) 679 | L11_hi, L12_hi, L13_hi, L14_hi, L15_hi = 680 | bit32_bxor(L12_hi, bit32_band(-1 - L13_hi, L14_hi)), 681 | bit32_bxor(L13_hi, bit32_band(-1 - L14_hi, L15_hi)), 682 | bit32_bxor(L14_hi, bit32_band(-1 - L15_hi, L11_hi)), 683 | bit32_bxor(L15_hi, bit32_band(-1 - L11_hi, L12_hi)), 684 | bit32_bxor(L11_hi, bit32_band(-1 - L12_hi, L13_hi)) 685 | L16_lo, L17_lo, L18_lo, L19_lo, L20_lo = 686 | bit32_bxor(L20_lo, bit32_band(-1 - L16_lo, L17_lo)), 687 | bit32_bxor(L16_lo, bit32_band(-1 - L17_lo, L18_lo)), 688 | bit32_bxor(L17_lo, bit32_band(-1 - L18_lo, L19_lo)), 689 | bit32_bxor(L18_lo, bit32_band(-1 - L19_lo, L20_lo)), 690 | bit32_bxor(L19_lo, bit32_band(-1 - L20_lo, L16_lo)) 691 | L16_hi, L17_hi, L18_hi, L19_hi, L20_hi = 692 | bit32_bxor(L20_hi, bit32_band(-1 - L16_hi, L17_hi)), 693 | bit32_bxor(L16_hi, bit32_band(-1 - L17_hi, L18_hi)), 694 | bit32_bxor(L17_hi, bit32_band(-1 - L18_hi, L19_hi)), 695 | bit32_bxor(L18_hi, bit32_band(-1 - L19_hi, L20_hi)), 696 | bit32_bxor(L19_hi, bit32_band(-1 - L20_hi, L16_hi)) 697 | L21_lo, L22_lo, L23_lo, L24_lo, L25_lo = 698 | bit32_bxor(L23_lo, bit32_band(-1 - L24_lo, L25_lo)), 699 | bit32_bxor(L24_lo, bit32_band(-1 - L25_lo, L21_lo)), 700 | bit32_bxor(L25_lo, bit32_band(-1 - L21_lo, L22_lo)), 701 | bit32_bxor(L21_lo, bit32_band(-1 - L22_lo, L23_lo)), 702 | bit32_bxor(L22_lo, bit32_band(-1 - L23_lo, L24_lo)) 703 | L21_hi, L22_hi, L23_hi, L24_hi, L25_hi = 704 | bit32_bxor(L23_hi, bit32_band(-1 - L24_hi, L25_hi)), 705 | bit32_bxor(L24_hi, bit32_band(-1 - L25_hi, L21_hi)), 706 | bit32_bxor(L25_hi, bit32_band(-1 - L21_hi, L22_hi)), 707 | bit32_bxor(L21_hi, bit32_band(-1 - L22_hi, L23_hi)), 708 | bit32_bxor(L22_hi, bit32_band(-1 - L23_hi, L24_hi)) 709 | L01_lo = bit32_bxor(L01_lo, RC_lo[round_idx]) 710 | L01_hi = L01_hi + RC_hi[round_idx] -- RC_hi[] is either 0 or 0x80000000, so we could use fast addition instead of slow XOR 711 | end 712 | 713 | lanes_lo[1] = L01_lo 714 | lanes_hi[1] = L01_hi 715 | lanes_lo[2] = L02_lo 716 | lanes_hi[2] = L02_hi 717 | lanes_lo[3] = L03_lo 718 | lanes_hi[3] = L03_hi 719 | lanes_lo[4] = L04_lo 720 | lanes_hi[4] = L04_hi 721 | lanes_lo[5] = L05_lo 722 | lanes_hi[5] = L05_hi 723 | lanes_lo[6] = L06_lo 724 | lanes_hi[6] = L06_hi 725 | lanes_lo[7] = L07_lo 726 | lanes_hi[7] = L07_hi 727 | lanes_lo[8] = L08_lo 728 | lanes_hi[8] = L08_hi 729 | lanes_lo[9] = L09_lo 730 | lanes_hi[9] = L09_hi 731 | lanes_lo[10] = L10_lo 732 | lanes_hi[10] = L10_hi 733 | lanes_lo[11] = L11_lo 734 | lanes_hi[11] = L11_hi 735 | lanes_lo[12] = L12_lo 736 | lanes_hi[12] = L12_hi 737 | lanes_lo[13] = L13_lo 738 | lanes_hi[13] = L13_hi 739 | lanes_lo[14] = L14_lo 740 | lanes_hi[14] = L14_hi 741 | lanes_lo[15] = L15_lo 742 | lanes_hi[15] = L15_hi 743 | lanes_lo[16] = L16_lo 744 | lanes_hi[16] = L16_hi 745 | lanes_lo[17] = L17_lo 746 | lanes_hi[17] = L17_hi 747 | lanes_lo[18] = L18_lo 748 | lanes_hi[18] = L18_hi 749 | lanes_lo[19] = L19_lo 750 | lanes_hi[19] = L19_hi 751 | lanes_lo[20] = L20_lo 752 | lanes_hi[20] = L20_hi 753 | lanes_lo[21] = L21_lo 754 | lanes_hi[21] = L21_hi 755 | lanes_lo[22] = L22_lo 756 | lanes_hi[22] = L22_hi 757 | lanes_lo[23] = L23_lo 758 | lanes_hi[23] = L23_hi 759 | lanes_lo[24] = L24_lo 760 | lanes_hi[24] = L24_hi 761 | lanes_lo[25] = L25_lo 762 | lanes_hi[25] = L25_hi 763 | end 764 | end 765 | 766 | -------------------------------------------------------------------------------- 767 | -- MAGIC NUMBERS CALCULATOR 768 | -------------------------------------------------------------------------------- 769 | -- Q: 770 | -- Is 53-bit "double" math enough to calculate square roots and cube roots of primes with 64 correct bits after decimal point? 771 | -- A: 772 | -- Yes, 53-bit "double" arithmetic is enough. 773 | -- We could obtain first 40 bits by direct calculation of p^(1/3) and next 40 bits by one step of Newton's method. 774 | do 775 | local function mul(src1, src2, factor, result_length) 776 | -- src1, src2 - long integers (arrays of digits in base TWO_POW_24) 777 | -- factor - small integer 778 | -- returns long integer result (src1 * src2 * factor) and its floating point approximation 779 | local result, carry, value, weight = table.create(result_length), 0.0, 0.0, 1.0 780 | for j = 1, result_length do 781 | for k = math.max(1, j + 1 - #src2), math.min(j, #src1) do 782 | carry = carry + factor * src1[k] * src2[j + 1 - k] -- "int32" is not enough for multiplication result, that's why "factor" must be of type "double" 783 | end 784 | 785 | local digit = carry % TWO_POW_24 786 | result[j] = math.floor(digit) 787 | carry = (carry - digit) / TWO_POW_24 788 | value = value + digit * weight 789 | weight = weight * TWO_POW_24 790 | end 791 | 792 | return result, value 793 | end 794 | 795 | local idx, step, p, one, sqrt_hi, sqrt_lo = 0, { 4, 1, 2, -2, 2 }, 4, { 1 }, sha2_H_hi, sha2_H_lo 796 | repeat 797 | p = p + step[p % 6] 798 | local d = 1 799 | repeat 800 | d = d + step[d % 6] 801 | if d * d > p then 802 | -- next prime number is found 803 | local root = p ^ (1 / 3) 804 | local R = root * TWO_POW_40 805 | R = mul(table.create(1, math.floor(R)), one, 1.0, 2) 806 | local _, delta = mul(R, mul(R, R, 1.0, 4), -1.0, 4) 807 | local hi = R[2] % 65536 * 65536 + math.floor(R[1] / 256) 808 | local lo = R[1] % 256 * 16777216 + math.floor(delta * (TWO_POW_NEG_56 / 3) * root / p) 809 | 810 | if idx < 16 then 811 | root = math.sqrt(p) 812 | R = root * TWO_POW_40 813 | R = mul(table.create(1, math.floor(R)), one, 1.0, 2) 814 | _, delta = mul(R, R, -1.0, 2) 815 | 816 | local hi = R[2] % 65536 * 65536 + math.floor(R[1] / 256) 817 | local lo = R[1] % 256 * 16777216 + math.floor(delta * TWO_POW_NEG_17 / root) 818 | local idx = idx % 8 + 1 819 | sha2_H_ext256[224][idx] = lo 820 | sqrt_hi[idx], sqrt_lo[idx] = hi, lo + hi * hi_factor 821 | 822 | if idx > 7 then 823 | sqrt_hi, sqrt_lo = sha2_H_ext512_hi[384], sha2_H_ext512_lo[384] 824 | end 825 | end 826 | 827 | idx = idx + 1 828 | sha2_K_hi[idx], sha2_K_lo[idx] = hi, lo % K_lo_modulo + hi * hi_factor 829 | break 830 | end 831 | until p % d == 0 832 | until idx > 79 833 | end 834 | 835 | -- Calculating IVs for SHA512/224 and SHA512/256 836 | for width = 224, 256, 32 do 837 | local H_lo, H_hi = {}, nil 838 | if XOR64A5 then 839 | for j = 1, 8 do 840 | H_lo[j] = XOR64A5(sha2_H_lo[j]) 841 | end 842 | else 843 | H_hi = {} 844 | for j = 1, 8 do 845 | H_lo[j] = bit32_bxor(sha2_H_lo[j], 0xA5A5A5A5) % 4294967296 846 | H_hi[j] = bit32_bxor(sha2_H_hi[j], 0xA5A5A5A5) % 4294967296 847 | end 848 | end 849 | 850 | sha512_feed_128(H_lo, H_hi, "SHA-512/" .. tostring(width) .. "\128" .. string.rep("\0", 115) .. "\88", 0, 128) 851 | sha2_H_ext512_lo[width] = H_lo 852 | sha2_H_ext512_hi[width] = H_hi 853 | end 854 | 855 | -- Constants for MD5 856 | do 857 | for idx = 1, 64 do 858 | -- we can't use formula math.floor(abs(sin(idx))*TWO_POW_32) because its result may be beyond integer range on Lua built with 32-bit integers 859 | local hi, lo = math.modf(math.abs(math.sin(idx)) * TWO_POW_16) 860 | md5_K[idx] = hi * 65536 + math.floor(lo * TWO_POW_16) 861 | end 862 | end 863 | 864 | -- Constants for SHA3 865 | do 866 | local sh_reg = 29 867 | local function next_bit() 868 | local r = sh_reg % 2 869 | sh_reg = bit32_bxor((sh_reg - r) / 2, 142 * r) 870 | return r 871 | end 872 | 873 | for idx = 1, 24 do 874 | local lo, m = 0, nil 875 | for _ = 1, 6 do 876 | m = m and m * m * 2 or 1 877 | lo = lo + next_bit() * m 878 | end 879 | 880 | local hi = next_bit() * m 881 | sha3_RC_hi[idx], sha3_RC_lo[idx] = hi, lo + hi * hi_factor_keccak 882 | end 883 | end 884 | 885 | -------------------------------------------------------------------------------- 886 | -- MAIN FUNCTIONS 887 | -------------------------------------------------------------------------------- 888 | local function sha256ext(width, message) 889 | -- Create an instance (private objects for current calculation) 890 | local Array256 = sha2_H_ext256[width] -- # == 8 891 | local length, tail = 0.0, "" 892 | local H = table.create(8) 893 | H[1], H[2], H[3], H[4], H[5], H[6], H[7], H[8] = 894 | Array256[1], Array256[2], Array256[3], Array256[4], Array256[5], Array256[6], Array256[7], Array256[8] 895 | 896 | local function partial(message_part) 897 | if message_part then 898 | local partLength = #message_part 899 | if tail then 900 | length = length + partLength 901 | local offs = 0 902 | if tail ~= "" and #tail + partLength >= 64 then 903 | offs = 64 - #tail 904 | sha256_feed_64(H, tail .. string.sub(message_part, 1, offs), 0, 64) 905 | tail = "" 906 | end 907 | 908 | local size = partLength - offs 909 | local size_tail = size % 64 910 | sha256_feed_64(H, message_part, offs, size - size_tail) 911 | tail = tail .. string.sub(message_part, partLength + 1 - size_tail) 912 | return partial 913 | else 914 | error("Adding more chunks is not allowed after receiving the result", 2) 915 | end 916 | else 917 | if tail then 918 | local final_blocks = table.create(10) --{tail, "\128", string.rep("\0", (-9 - length) % 64 + 1)} 919 | final_blocks[1] = tail 920 | final_blocks[2] = "\128" 921 | final_blocks[3] = string.rep("\0", (-9 - length) % 64 + 1) 922 | 923 | tail = nil 924 | -- Assuming user data length is shorter than (TWO_POW_53)-9 bytes 925 | -- Anyway, it looks very unrealistic that someone would spend more than a year of calculations to process TWO_POW_53 bytes of data by using this Lua script :-) 926 | -- TWO_POW_53 bytes = TWO_POW_56 bits, so "bit-counter" fits in 7 bytes 927 | length = length * (8 / TWO56_POW_7) -- convert "byte-counter" to "bit-counter" and move decimal point to the left 928 | for j = 4, 10 do 929 | length = length % 1 * 256 930 | final_blocks[j] = string.char(math.floor(length)) 931 | end 932 | 933 | final_blocks = table.concat(final_blocks) 934 | sha256_feed_64(H, final_blocks, 0, #final_blocks) 935 | local max_reg = width / 32 936 | for j = 1, max_reg do 937 | H[j] = string.format("%08x", H[j] % 4294967296) 938 | end 939 | 940 | H = table.concat(H, "", 1, max_reg) 941 | end 942 | 943 | return H 944 | end 945 | end 946 | 947 | if message then 948 | -- Actually perform calculations and return the SHA256 digest of a message 949 | return partial(message)() 950 | else 951 | -- Return function for chunk-by-chunk loading 952 | -- User should feed every chunk of input data as single argument to this function and finally get SHA256 digest by invoking this function without an argument 953 | return partial 954 | end 955 | end 956 | 957 | local function sha512ext(width, message) 958 | -- Create an instance (private objects for current calculation) 959 | local length, tail, H_lo, H_hi = 960 | 0.0, 961 | "", 962 | table.pack(table.unpack(sha2_H_ext512_lo[width])), 963 | not HEX64 and table.pack(table.unpack(sha2_H_ext512_hi[width])) 964 | 965 | local function partial(message_part) 966 | if message_part then 967 | local partLength = #message_part 968 | if tail then 969 | length = length + partLength 970 | local offs = 0 971 | if tail ~= "" and #tail + partLength >= 128 then 972 | offs = 128 - #tail 973 | sha512_feed_128(H_lo, H_hi, tail .. string.sub(message_part, 1, offs), 0, 128) 974 | tail = "" 975 | end 976 | 977 | local size = partLength - offs 978 | local size_tail = size % 128 979 | sha512_feed_128(H_lo, H_hi, message_part, offs, size - size_tail) 980 | tail = tail .. string.sub(message_part, partLength + 1 - size_tail) 981 | return partial 982 | else 983 | error("Adding more chunks is not allowed after receiving the result", 2) 984 | end 985 | else 986 | if tail then 987 | local final_blocks = table.create(10) --{tail, "\128", string.rep("\0", (-17-length) % 128 + 9)} 988 | final_blocks[1] = tail 989 | final_blocks[2] = "\128" 990 | final_blocks[3] = string.rep("\0", (-17 - length) % 128 + 9) 991 | 992 | tail = nil 993 | -- Assuming user data length is shorter than (TWO_POW_53)-17 bytes 994 | -- TWO_POW_53 bytes = TWO_POW_56 bits, so "bit-counter" fits in 7 bytes 995 | length = length * (8 / TWO56_POW_7) -- convert "byte-counter" to "bit-counter" and move floating point to the left 996 | for j = 4, 10 do 997 | length = length % 1 * 256 998 | final_blocks[j] = string.char(math.floor(length)) 999 | end 1000 | 1001 | final_blocks = table.concat(final_blocks) 1002 | sha512_feed_128(H_lo, H_hi, final_blocks, 0, #final_blocks) 1003 | local max_reg = math.ceil(width / 64) 1004 | 1005 | if HEX64 then 1006 | for j = 1, max_reg do 1007 | H_lo[j] = HEX64(H_lo[j]) 1008 | end 1009 | else 1010 | for j = 1, max_reg do 1011 | H_lo[j] = string.format("%08x", H_hi[j] % 4294967296) 1012 | .. string.format("%08x", H_lo[j] % 4294967296) 1013 | end 1014 | 1015 | H_hi = nil 1016 | end 1017 | 1018 | H_lo = string.sub(table.concat(H_lo, "", 1, max_reg), 1, width / 4) 1019 | end 1020 | 1021 | return H_lo 1022 | end 1023 | end 1024 | 1025 | if message then 1026 | -- Actually perform calculations and return the SHA512 digest of a message 1027 | return partial(message)() 1028 | else 1029 | -- Return function for chunk-by-chunk loading 1030 | -- User should feed every chunk of input data as single argument to this function and finally get SHA512 digest by invoking this function without an argument 1031 | return partial 1032 | end 1033 | end 1034 | 1035 | local function md5(message) 1036 | -- Create an instance (private objects for current calculation) 1037 | local H, length, tail = table.create(4), 0.0, "" 1038 | H[1], H[2], H[3], H[4] = md5_sha1_H[1], md5_sha1_H[2], md5_sha1_H[3], md5_sha1_H[4] 1039 | 1040 | local function partial(message_part) 1041 | if message_part then 1042 | local partLength = #message_part 1043 | if tail then 1044 | length = length + partLength 1045 | local offs = 0 1046 | if tail ~= "" and #tail + partLength >= 64 then 1047 | offs = 64 - #tail 1048 | md5_feed_64(H, tail .. string.sub(message_part, 1, offs), 0, 64) 1049 | tail = "" 1050 | end 1051 | 1052 | local size = partLength - offs 1053 | local size_tail = size % 64 1054 | md5_feed_64(H, message_part, offs, size - size_tail) 1055 | tail = tail .. string.sub(message_part, partLength + 1 - size_tail) 1056 | return partial 1057 | else 1058 | error("Adding more chunks is not allowed after receiving the result", 2) 1059 | end 1060 | else 1061 | if tail then 1062 | local final_blocks = table.create(11) --{tail, "\128", string.rep("\0", (-9 - length) % 64)} 1063 | final_blocks[1] = tail 1064 | final_blocks[2] = "\128" 1065 | final_blocks[3] = string.rep("\0", (-9 - length) % 64) 1066 | 1067 | tail = nil 1068 | length = length * 8 -- convert "byte-counter" to "bit-counter" 1069 | for j = 4, 11 do 1070 | local low_byte = length % 256 1071 | final_blocks[j] = string.char(low_byte) 1072 | length = (length - low_byte) / 256 1073 | end 1074 | 1075 | final_blocks = table.concat(final_blocks) 1076 | md5_feed_64(H, final_blocks, 0, #final_blocks) 1077 | for j = 1, 4 do 1078 | H[j] = string.format("%08x", H[j] % 4294967296) 1079 | end 1080 | 1081 | H = string.gsub(table.concat(H), "(..)(..)(..)(..)", "%4%3%2%1") 1082 | end 1083 | 1084 | return H 1085 | end 1086 | end 1087 | 1088 | if message then 1089 | -- Actually perform calculations and return the MD5 digest of a message 1090 | return partial(message)() 1091 | else 1092 | -- Return function for chunk-by-chunk loading 1093 | -- User should feed every chunk of input data as single argument to this function and finally get MD5 digest by invoking this function without an argument 1094 | return partial 1095 | end 1096 | end 1097 | 1098 | local function sha1(message) 1099 | -- Create an instance (private objects for current calculation) 1100 | local H, length, tail = table.pack(table.unpack(md5_sha1_H)), 0.0, "" 1101 | 1102 | local function partial(message_part) 1103 | if message_part then 1104 | local partLength = #message_part 1105 | if tail then 1106 | length = length + partLength 1107 | local offs = 0 1108 | if tail ~= "" and #tail + partLength >= 64 then 1109 | offs = 64 - #tail 1110 | sha1_feed_64(H, tail .. string.sub(message_part, 1, offs), 0, 64) 1111 | tail = "" 1112 | end 1113 | 1114 | local size = partLength - offs 1115 | local size_tail = size % 64 1116 | sha1_feed_64(H, message_part, offs, size - size_tail) 1117 | tail = tail .. string.sub(message_part, partLength + 1 - size_tail) 1118 | return partial 1119 | else 1120 | error("Adding more chunks is not allowed after receiving the result", 2) 1121 | end 1122 | else 1123 | if tail then 1124 | local final_blocks = table.create(10) --{tail, "\128", string.rep("\0", (-9 - length) % 64 + 1)} 1125 | final_blocks[1] = tail 1126 | final_blocks[2] = "\128" 1127 | final_blocks[3] = string.rep("\0", (-9 - length) % 64 + 1) 1128 | 1129 | tail = nil 1130 | -- Assuming user data length is shorter than (TWO_POW_53)-9 bytes 1131 | -- TWO_POW_53 bytes = TWO_POW_56 bits, so "bit-counter" fits in 7 bytes 1132 | length = length * (8 / TWO56_POW_7) -- convert "byte-counter" to "bit-counter" and move decimal point to the left 1133 | for j = 4, 10 do 1134 | length = length % 1 * 256 1135 | final_blocks[j] = string.char(math.floor(length)) 1136 | end 1137 | 1138 | final_blocks = table.concat(final_blocks) 1139 | sha1_feed_64(H, final_blocks, 0, #final_blocks) 1140 | for j = 1, 5 do 1141 | H[j] = string.format("%08x", H[j] % 4294967296) 1142 | end 1143 | 1144 | H = table.concat(H) 1145 | end 1146 | 1147 | return H 1148 | end 1149 | end 1150 | 1151 | if message then 1152 | -- Actually perform calculations and return the SHA-1 digest of a message 1153 | return partial(message)() 1154 | else 1155 | -- Return function for chunk-by-chunk loading 1156 | -- User should feed every chunk of input data as single argument to this function and finally get SHA-1 digest by invoking this function without an argument 1157 | return partial 1158 | end 1159 | end 1160 | 1161 | local function keccak(block_size_in_bytes, digest_size_in_bytes, is_SHAKE, message) 1162 | -- "block_size_in_bytes" is multiple of 8 1163 | if type(digest_size_in_bytes) ~= "number" then 1164 | -- arguments in SHAKE are swapped: 1165 | -- NIST FIPS 202 defines SHAKE(message,num_bits) 1166 | -- this module defines SHAKE(num_bytes,message) 1167 | -- it's easy to forget about this swap, hence the check 1168 | error("Argument 'digest_size_in_bytes' must be a number", 2) 1169 | end 1170 | 1171 | -- Create an instance (private objects for current calculation) 1172 | local tail, lanes_lo, lanes_hi = "", table.create(25, 0), hi_factor_keccak == 0 and table.create(25, 0) 1173 | local result 1174 | 1175 | --~ pad the input N using the pad function, yielding a padded bit string P with a length divisible by r (such that n = len(P)/r is integer), 1176 | --~ break P into n consecutive r-bit pieces P0, ..., Pn-1 (last is zero-padded) 1177 | --~ initialize the state S to a string of b 0 bits. 1178 | --~ absorb the input into the state: For each block Pi, 1179 | --~ extend Pi at the end by a string of c 0 bits, yielding one of length b, 1180 | --~ XOR that with S and 1181 | --~ apply the block permutation f to the result, yielding a new state S 1182 | --~ initialize Z to be the empty string 1183 | --~ while the length of Z is less than d: 1184 | --~ append the first r bits of S to Z 1185 | --~ if Z is still less than d bits long, apply f to S, yielding a new state S. 1186 | --~ truncate Z to d bits 1187 | local function partial(message_part) 1188 | if message_part then 1189 | local partLength = #message_part 1190 | if tail then 1191 | local offs = 0 1192 | if tail ~= "" and #tail + partLength >= block_size_in_bytes then 1193 | offs = block_size_in_bytes - #tail 1194 | keccak_feed( 1195 | lanes_lo, 1196 | lanes_hi, 1197 | tail .. string.sub(message_part, 1, offs), 1198 | 0, 1199 | block_size_in_bytes, 1200 | block_size_in_bytes 1201 | ) 1202 | tail = "" 1203 | end 1204 | 1205 | local size = partLength - offs 1206 | local size_tail = size % block_size_in_bytes 1207 | keccak_feed(lanes_lo, lanes_hi, message_part, offs, size - size_tail, block_size_in_bytes) 1208 | tail = tail .. string.sub(message_part, partLength + 1 - size_tail) 1209 | return partial 1210 | else 1211 | error("Adding more chunks is not allowed after receiving the result", 2) 1212 | end 1213 | else 1214 | if tail then 1215 | -- append the following bits to the message: for usual SHA3: 011(0*)1, for SHAKE: 11111(0*)1 1216 | local gap_start = is_SHAKE and 31 or 6 1217 | tail = tail 1218 | .. ( 1219 | #tail + 1 == block_size_in_bytes and string.char(gap_start + 128) 1220 | or string.char(gap_start) .. string.rep("\0", (-2 - #tail) % block_size_in_bytes) .. "\128" 1221 | ) 1222 | keccak_feed(lanes_lo, lanes_hi, tail, 0, #tail, block_size_in_bytes) 1223 | tail = nil 1224 | 1225 | local lanes_used = 0 1226 | local total_lanes = math.floor(block_size_in_bytes / 8) 1227 | local qwords = {} 1228 | 1229 | local function get_next_qwords_of_digest(qwords_qty) 1230 | -- returns not more than 'qwords_qty' qwords ('qwords_qty' might be non-integer) 1231 | -- doesn't go across keccak-buffer boundary 1232 | -- block_size_in_bytes is a multiple of 8, so, keccak-buffer contains integer number of qwords 1233 | if lanes_used >= total_lanes then 1234 | keccak_feed(lanes_lo, lanes_hi, "\0\0\0\0\0\0\0\0", 0, 8, 8) 1235 | lanes_used = 0 1236 | end 1237 | 1238 | qwords_qty = math.floor(math.min(qwords_qty, total_lanes - lanes_used)) 1239 | if hi_factor_keccak ~= 0 then 1240 | for j = 1, qwords_qty do 1241 | qwords[j] = HEX64(lanes_lo[lanes_used + j - 1 + lanes_index_base]) 1242 | end 1243 | else 1244 | for j = 1, qwords_qty do 1245 | qwords[j] = string.format("%08x", lanes_hi[lanes_used + j] % 4294967296) 1246 | .. string.format("%08x", lanes_lo[lanes_used + j] % 4294967296) 1247 | end 1248 | end 1249 | 1250 | lanes_used = lanes_used + qwords_qty 1251 | return string.gsub( 1252 | table.concat(qwords, "", 1, qwords_qty), 1253 | "(..)(..)(..)(..)(..)(..)(..)(..)", 1254 | "%8%7%6%5%4%3%2%1" 1255 | ), 1256 | qwords_qty * 8 1257 | end 1258 | 1259 | local parts = {} -- digest parts 1260 | local last_part, last_part_size = "", 0 1261 | 1262 | local function get_next_part_of_digest(bytes_needed) 1263 | -- returns 'bytes_needed' bytes, for arbitrary integer 'bytes_needed' 1264 | bytes_needed = bytes_needed or 1 1265 | if bytes_needed <= last_part_size then 1266 | last_part_size = last_part_size - bytes_needed 1267 | local part_size_in_nibbles = bytes_needed * 2 1268 | local result = string.sub(last_part, 1, part_size_in_nibbles) 1269 | last_part = string.sub(last_part, part_size_in_nibbles + 1) 1270 | return result 1271 | end 1272 | 1273 | local parts_qty = 0 1274 | if last_part_size > 0 then 1275 | parts_qty = 1 1276 | parts[parts_qty] = last_part 1277 | bytes_needed = bytes_needed - last_part_size 1278 | end 1279 | 1280 | -- repeats until the length is enough 1281 | while bytes_needed >= 8 do 1282 | local next_part, next_part_size = get_next_qwords_of_digest(bytes_needed / 8) 1283 | parts_qty = parts_qty + 1 1284 | parts[parts_qty] = next_part 1285 | bytes_needed = bytes_needed - next_part_size 1286 | end 1287 | 1288 | if bytes_needed > 0 then 1289 | last_part, last_part_size = get_next_qwords_of_digest(1) 1290 | parts_qty = parts_qty + 1 1291 | parts[parts_qty] = get_next_part_of_digest(bytes_needed) 1292 | else 1293 | last_part, last_part_size = "", 0 1294 | end 1295 | 1296 | return table.concat(parts, "", 1, parts_qty) 1297 | end 1298 | 1299 | if digest_size_in_bytes < 0 then 1300 | result = get_next_part_of_digest 1301 | else 1302 | result = get_next_part_of_digest(digest_size_in_bytes) 1303 | end 1304 | end 1305 | 1306 | return result 1307 | end 1308 | end 1309 | 1310 | if message then 1311 | -- Actually perform calculations and return the SHA3 digest of a message 1312 | return partial(message)() 1313 | else 1314 | -- Return function for chunk-by-chunk loading 1315 | -- User should feed every chunk of input data as single argument to this function and finally get SHA3 digest by invoking this function without an argument 1316 | return partial 1317 | end 1318 | end 1319 | 1320 | local function HexToBinFunction(hh) 1321 | return string.char(tonumber(hh, 16)) 1322 | end 1323 | 1324 | local function hex2bin(hex_string) 1325 | return (string.gsub(hex_string, "%x%x", HexToBinFunction)) 1326 | end 1327 | 1328 | local base64_symbols = { 1329 | ["+"] = 62, 1330 | ["-"] = 62, 1331 | [62] = "+", 1332 | ["/"] = 63, 1333 | ["_"] = 63, 1334 | [63] = "/", 1335 | ["="] = -1, 1336 | ["."] = -1, 1337 | [-1] = "=", 1338 | } 1339 | 1340 | local symbol_index = 0 1341 | for j, pair in ipairs({ "AZ", "az", "09" }) do 1342 | for ascii = string.byte(pair), string.byte(pair, 2) do 1343 | local ch = string.char(ascii) 1344 | base64_symbols[ch] = symbol_index 1345 | base64_symbols[symbol_index] = ch 1346 | symbol_index = symbol_index + 1 1347 | end 1348 | end 1349 | 1350 | local function bin2base64(binary_string) 1351 | local result = table.create(math.ceil(#binary_string / 3)) 1352 | local length = 0 1353 | 1354 | for pos = 1, #binary_string, 3 do 1355 | local c1, c2, c3, c4 = string.byte(string.sub(binary_string, pos, pos + 2) .. "\0", 1, -1) 1356 | length = length + 1 1357 | result[length] = base64_symbols[math.floor(c1 / 4)] 1358 | .. base64_symbols[c1 % 4 * 16 + math.floor(c2 / 16)] 1359 | .. base64_symbols[c3 and c2 % 16 * 4 + math.floor(c3 / 64) or -1] 1360 | .. base64_symbols[c4 and c3 % 64 or -1] 1361 | end 1362 | 1363 | return table.concat(result) 1364 | end 1365 | 1366 | local function base642bin(base64_string) 1367 | local result, chars_qty = {}, 3 1368 | for pos, ch in string.gmatch(string.gsub(base64_string, "%s+", ""), "()(.)") do 1369 | local code = base64_symbols[ch] 1370 | if code < 0 then 1371 | chars_qty = chars_qty - 1 1372 | code = 0 1373 | end 1374 | 1375 | local idx = pos % 4 1376 | if idx > 0 then 1377 | result[-idx] = code 1378 | else 1379 | local c1 = result[-1] * 4 + math.floor(result[-2] / 16) 1380 | local c2 = (result[-2] % 16) * 16 + math.floor(result[-3] / 4) 1381 | local c3 = (result[-3] % 4) * 64 + code 1382 | result[#result + 1] = string.sub(string.char(c1, c2, c3), 1, chars_qty) 1383 | end 1384 | end 1385 | 1386 | return table.concat(result) 1387 | end 1388 | 1389 | local block_size_for_HMAC -- this table will be initialized at the end of the module 1390 | local function pad_and_xor(str, result_length, byte_for_xor) 1391 | return string.gsub(str, ".", function(c) 1392 | return string.char(bit32_bxor(string.byte(c), byte_for_xor)) 1393 | end) .. string.rep(string.char(byte_for_xor), result_length - #str) 1394 | end 1395 | 1396 | -- For the sake of speed of converting hexes to strings, there's a map of the conversions here 1397 | local BinaryStringMap = {} 1398 | for Index = 0, 255 do 1399 | BinaryStringMap[string.format("%02x", Index)] = string.char(Index) 1400 | end 1401 | 1402 | -- Update 02.14.20 - added AsBinary for easy GameAnalytics replacement. 1403 | local function hmac(hash_func, key, message, AsBinary) 1404 | -- Create an instance (private objects for current calculation) 1405 | local block_size = block_size_for_HMAC[hash_func] 1406 | if not block_size then 1407 | error("Unknown hash function", 2) 1408 | end 1409 | 1410 | if #key > block_size then 1411 | key = string.gsub(hash_func(key), "%x%x", HexToBinFunction) 1412 | --key = hex2bin(hash_func(key)) 1413 | end 1414 | 1415 | local append = hash_func()(pad_and_xor(key, block_size, 0x36)) 1416 | local result 1417 | 1418 | local function partial(message_part) 1419 | if not message_part then 1420 | result = result 1421 | or hash_func(pad_and_xor(key, block_size, 0x5C) .. (string.gsub(append(), "%x%x", HexToBinFunction))) 1422 | return result 1423 | elseif result then 1424 | error("Adding more chunks is not allowed after receiving the result", 2) 1425 | else 1426 | append(message_part) 1427 | return partial 1428 | end 1429 | end 1430 | 1431 | if message then 1432 | -- Actually perform calculations and return the HMAC of a message 1433 | local FinalMessage = partial(message)() 1434 | return AsBinary and (string.gsub(FinalMessage, "%x%x", BinaryStringMap)) or FinalMessage 1435 | else 1436 | -- Return function for chunk-by-chunk loading of a message 1437 | -- User should feed every chunk of the message as single argument to this function and finally get HMAC by invoking this function without an argument 1438 | return partial 1439 | end 1440 | end 1441 | 1442 | local sha = { 1443 | md5 = md5, 1444 | sha1 = sha1, 1445 | -- SHA2 hash functions: 1446 | sha224 = function(message) 1447 | return sha256ext(224, message) 1448 | end, 1449 | 1450 | sha256 = function(message) 1451 | return sha256ext(256, message) 1452 | end, 1453 | 1454 | sha512_224 = function(message) 1455 | return sha512ext(224, message) 1456 | end, 1457 | 1458 | sha512_256 = function(message) 1459 | return sha512ext(256, message) 1460 | end, 1461 | 1462 | sha384 = function(message) 1463 | return sha512ext(384, message) 1464 | end, 1465 | 1466 | sha512 = function(message) 1467 | return sha512ext(512, message) 1468 | end, 1469 | 1470 | -- SHA3 hash functions: 1471 | sha3_224 = function(message) 1472 | return keccak((1600 - 2 * 224) / 8, 224 / 8, false, message) 1473 | end, 1474 | 1475 | sha3_256 = function(message) 1476 | return keccak((1600 - 2 * 256) / 8, 256 / 8, false, message) 1477 | end, 1478 | 1479 | sha3_384 = function(message) 1480 | return keccak((1600 - 2 * 384) / 8, 384 / 8, false, message) 1481 | end, 1482 | 1483 | sha3_512 = function(message) 1484 | return keccak((1600 - 2 * 512) / 8, 512 / 8, false, message) 1485 | end, 1486 | 1487 | shake128 = function(message, digest_size_in_bytes) 1488 | return keccak((1600 - 2 * 128) / 8, digest_size_in_bytes, true, message) 1489 | end, 1490 | 1491 | shake256 = function(message, digest_size_in_bytes) 1492 | return keccak((1600 - 2 * 256) / 8, digest_size_in_bytes, true, message) 1493 | end, 1494 | 1495 | -- misc utilities: 1496 | hmac = hmac, -- HMAC(hash_func, key, message) is applicable to any hash function from this module except SHAKE* 1497 | hex_to_bin = hex2bin, -- converts hexadecimal representation to binary string 1498 | base64_to_bin = base642bin, -- converts base64 representation to binary string 1499 | bin_to_base64 = bin2base64, 1500 | base64_encode = Base64.Encode, 1501 | base64_decode = Base64.Decode, 1502 | -- converts binary string to base64 representation 1503 | } 1504 | 1505 | block_size_for_HMAC = { 1506 | [sha.md5] = 64, 1507 | [sha.sha1] = 64, 1508 | [sha.sha224] = 64, 1509 | [sha.sha256] = 64, 1510 | [sha.sha512_224] = 128, 1511 | [sha.sha512_256] = 128, 1512 | [sha.sha384] = 128, 1513 | [sha.sha512] = 128, 1514 | [sha.sha3_224] = (1600 - 2 * 224) / 8, 1515 | [sha.sha3_256] = (1600 - 2 * 256) / 8, 1516 | [sha.sha3_384] = (1600 - 2 * 384) / 8, 1517 | [sha.sha3_512] = (1600 - 2 * 512) / 8, 1518 | } 1519 | 1520 | return sha 1521 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/HttpApi/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | local validation = require(script.Parent.Validation) 3 | local version = require(script.Parent.Version) 4 | 5 | local HashLib = require(script.HashLib) 6 | 7 | local http_api = { 8 | protocol = "https", 9 | hostName = "api.gameanalytics.com", 10 | version = "v2", 11 | remoteConfigsVersion = "v1", 12 | initializeUrlPath = "init", 13 | eventsUrlPath = "events", 14 | EGAHTTPApiResponse = { 15 | NoResponse = 0, 16 | BadResponse = 1, 17 | RequestTimeout = 2, 18 | JsonEncodeFailed = 3, 19 | JsonDecodeFailed = 4, 20 | InternalServerError = 5, 21 | BadRequest = 6, 22 | Unauthorized = 7, 23 | UnknownResponseCode = 8, 24 | Ok = 9, 25 | Created = 10, 26 | }, 27 | } 28 | 29 | local HTTP = game:GetService("HttpService") 30 | local logger = require(script.Parent.Logger) 31 | local baseUrl = (RunService:IsStudio() and "https" or http_api.protocol) 32 | .. "://" 33 | .. (RunService:IsStudio() and "sandbox-" or "") 34 | .. http_api.hostName 35 | .. "/" 36 | .. http_api.version 37 | local remoteConfigsBaseUrl = (RunService:IsStudio() and "https" or http_api.protocol) 38 | .. "://" 39 | .. (RunService:IsStudio() and "sandbox-" or "") 40 | .. http_api.hostName 41 | .. "/remote_configs/" 42 | .. http_api.remoteConfigsVersion 43 | 44 | local function getInitAnnotations(build, playerData, playerId) 45 | local initAnnotations = { 46 | ["user_id"] = tostring(playerId) .. playerData.CustomUserId, 47 | ["sdk_version"] = "roblox " .. version.SdkVersion, 48 | ["os_version"] = playerData.OS, 49 | ["platform"] = playerData.Platform, 50 | ["build"] = build, 51 | ["session_num"] = playerData.Sessions, 52 | ["random_salt"] = playerData.Sessions, 53 | } 54 | 55 | return initAnnotations 56 | end 57 | 58 | local function encode(payload, secretKey) 59 | --Validate 60 | if not secretKey then 61 | logger:w("Error encoding, invalid SecretKey") 62 | return 63 | end 64 | 65 | --Encode 66 | local payloadHmac = HashLib.hmac( 67 | HashLib.sha256, 68 | RunService:IsStudio() and "16813a12f718bc5c620f56944e1abc3ea13ccbac" or secretKey, 69 | payload, 70 | true 71 | ) 72 | 73 | return HashLib.base64_encode(payloadHmac) 74 | end 75 | 76 | local function processRequestResponse(response, requestId) 77 | local statusCode = response.StatusCode 78 | local body = response.Body 79 | 80 | if not body or #body == 0 then 81 | logger:d(requestId .. " request. failed. Might be no connection. Status code: " .. tostring(statusCode)) 82 | return http_api.EGAHTTPApiResponse.NoResponse 83 | end 84 | 85 | if statusCode == 200 then 86 | return http_api.EGAHTTPApiResponse.Ok 87 | elseif statusCode == 201 then 88 | return http_api.EGAHTTPApiResponse.Created 89 | elseif statusCode == 0 or statusCode == 401 then 90 | logger:d(requestId .. " request. 401 - Unauthorized.") 91 | return http_api.EGAHTTPApiResponse.Unauthorized 92 | elseif statusCode == 400 then 93 | logger:d(requestId .. " request. 400 - Bad Request.") 94 | return http_api.EGAHTTPApiResponse.BadRequest 95 | elseif statusCode == 500 then 96 | logger:d(requestId .. " request. 500 - Internal Server Error.") 97 | return http_api.EGAHTTPApiResponse.InternalServerError 98 | else 99 | return http_api.EGAHTTPApiResponse.UnknownResponseCode 100 | end 101 | end 102 | 103 | function http_api:initRequest(gameKey, secretKey, build, playerData, playerId) 104 | local url = remoteConfigsBaseUrl 105 | .. "/" 106 | .. http_api.initializeUrlPath 107 | .. "?game_key=" 108 | .. gameKey 109 | .. "&interval_seconds=0&configs_hash=" 110 | .. (playerData.ConfigsHash or "") 111 | if RunService:IsStudio() then 112 | url = baseUrl .. "/5c6bcb5402204249437fb5a7a80a4959/" .. self.initializeUrlPath 113 | end 114 | 115 | logger:d("Sending 'init' URL: " .. url) 116 | 117 | local payload = HTTP:JSONEncode(getInitAnnotations(build, playerData, playerId)) 118 | payload = payload:gsub('"country_code":"unknown"', '"country_code":null') 119 | local authorization = encode(payload, secretKey) 120 | 121 | logger:d("init payload: " .. payload) 122 | 123 | local res 124 | local success, err = pcall(function() 125 | res = HTTP:RequestAsync({ 126 | Url = url, 127 | Method = "POST", 128 | Headers = { 129 | ["Authorization"] = authorization, 130 | ["Content-Type"] = "application/json", 131 | }, 132 | Body = payload, 133 | }) 134 | end) 135 | 136 | if not success then 137 | logger:d("Failed Init Call. error: " .. err) 138 | return { 139 | statusCode = http_api.EGAHTTPApiResponse.UnknownResponseCode, 140 | body = nil, 141 | } 142 | end 143 | logger:d("init request content: " .. res.Body) 144 | 145 | local requestResponseEnum = processRequestResponse(res, "Init") 146 | 147 | -- if not 200 result 148 | if 149 | requestResponseEnum ~= http_api.EGAHTTPApiResponse.Ok 150 | and requestResponseEnum ~= http_api.EGAHTTPApiResponse.Created 151 | and requestResponseEnum ~= http_api.EGAHTTPApiResponse.BadRequest 152 | then 153 | logger:d( 154 | "Failed Init Call. URL: " .. url .. ", JSONString: " .. payload .. ", Authorization: " .. authorization 155 | ) 156 | return { 157 | statusCode = requestResponseEnum, 158 | body = nil, 159 | } 160 | end 161 | 162 | --Response 163 | local responseBody 164 | success = pcall(function() 165 | responseBody = HTTP:JSONDecode(res.Body) 166 | end) 167 | 168 | if not success then 169 | logger:d("Failed Init Call. Json decoding failed: " .. err) 170 | return { 171 | statusCode = http_api.EGAHTTPApiResponse.JsonDecodeFailed, 172 | body = nil, 173 | } 174 | end 175 | 176 | -- print reason if bad request 177 | if requestResponseEnum == http_api.EGAHTTPApiResponse.BadRequest then 178 | logger:d("Failed Init Call. Bad request. Response: " .. res.Body) 179 | return { 180 | statusCode = requestResponseEnum, 181 | body = nil, 182 | } 183 | end 184 | 185 | -- validate Init call values 186 | local validatedInitValues = validation:validateAndCleanInitRequestResponse( 187 | responseBody, 188 | requestResponseEnum == http_api.EGAHTTPApiResponse.Created 189 | ) 190 | 191 | if not validatedInitValues then 192 | return { 193 | statusCode = http_api.EGAHTTPApiResponse.BadResponse, 194 | body = nil, 195 | } 196 | end 197 | 198 | -- all ok 199 | return { 200 | statusCode = requestResponseEnum, 201 | body = responseBody, 202 | } 203 | end 204 | 205 | function http_api:sendEventsInArray(gameKey, secretKey, eventArray) 206 | if not eventArray or #eventArray == 0 then 207 | logger:d("sendEventsInArray called with missing eventArray") 208 | return 209 | end 210 | 211 | -- Generate URL 212 | local url = baseUrl .. "/" .. gameKey .. "/" .. self.eventsUrlPath 213 | if RunService:IsStudio() then 214 | url = baseUrl .. "/5c6bcb5402204249437fb5a7a80a4959/" .. self.eventsUrlPath 215 | end 216 | 217 | logger:d("Sending 'events' URL: " .. url) 218 | 219 | -- make JSON string from data 220 | local payload = HTTP:JSONEncode(eventArray) 221 | payload = payload:gsub('"country_code":"unknown"', '"country_code":null') 222 | local authorization = encode(payload, secretKey) 223 | 224 | local res 225 | local success, err = pcall(function() 226 | res = HTTP:RequestAsync({ 227 | Url = url, 228 | Method = "POST", 229 | Headers = { 230 | ["Authorization"] = authorization, 231 | ["Content-Type"] = "application/json", 232 | }, 233 | Body = payload, 234 | }) 235 | end) 236 | 237 | if not success then 238 | logger:d("Failed Events Call. error: " .. err) 239 | return { 240 | statusCode = http_api.EGAHTTPApiResponse.UnknownResponseCode, 241 | body = nil, 242 | } 243 | end 244 | 245 | logger:d("body: " .. res.Body) 246 | local requestResponseEnum = processRequestResponse(res, "Events") 247 | 248 | -- if not 200 result 249 | if 250 | requestResponseEnum ~= http_api.EGAHTTPApiResponse.Ok 251 | and requestResponseEnum ~= http_api.EGAHTTPApiResponse.Created 252 | and requestResponseEnum ~= http_api.EGAHTTPApiResponse.BadRequest 253 | then 254 | logger:d( 255 | "Failed Events Call. URL: " .. url .. ", JSONString: " .. payload .. ", Authorization: " .. authorization 256 | ) 257 | return { 258 | statusCode = requestResponseEnum, 259 | body = nil, 260 | } 261 | end 262 | 263 | local responseBody 264 | pcall(function() 265 | responseBody = HTTP:JSONDecode(res.Body) 266 | end) 267 | 268 | if not responseBody then 269 | logger:d("Failed Events Call. Json decoding failed") 270 | return { 271 | statusCode = http_api.EGAHTTPApiResponse.JsonDecodeFailed, 272 | body = nil, 273 | } 274 | end 275 | 276 | -- print reason if bad request 277 | if requestResponseEnum == http_api.EGAHTTPApiResponse.BadRequest then 278 | logger:d("Failed Events Call. Bad request. Response: " .. res.Body) 279 | return { 280 | statusCode = requestResponseEnum, 281 | body = nil, 282 | } 283 | end 284 | 285 | -- all ok 286 | return { 287 | statusCode = http_api.EGAHTTPApiResponse.Ok, 288 | body = responseBody, 289 | } 290 | end 291 | 292 | return http_api 293 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Logger.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | --local GameAnalyticsSendMessage 3 | 4 | local logger = { 5 | _infoLogEnabled = false, 6 | _infoLogAdvancedEnabled = false, 7 | _debugEnabled = RunService:IsStudio(), 8 | } 9 | 10 | function logger:setDebugLog(enabled) 11 | self._debugEnabled = enabled 12 | end 13 | 14 | function logger:setInfoLog(enabled) 15 | self._infoLogEnabled = enabled 16 | end 17 | 18 | function logger:setVerboseLog(enabled) 19 | self._infoLogAdvancedEnabled = enabled 20 | end 21 | 22 | function logger:i(format) 23 | if not self._infoLogEnabled then 24 | return 25 | end 26 | 27 | local m = "Info/GameAnalytics: " .. format 28 | print(m) 29 | -- GameAnalyticsSendMessage = GameAnalyticsSendMessage or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsSendMessage") 30 | -- GameAnalyticsSendMessage:FireAllClients({ 31 | -- Text = m, 32 | -- Font = Enum.Font.Arial, 33 | -- Color = Color3.new(255, 255, 255), 34 | -- FontSize = Enum.FontSize.Size96 35 | -- }) 36 | end 37 | 38 | function logger:w(format) 39 | local m = "Warning/GameAnalytics: " .. format 40 | warn(m) 41 | -- GameAnalyticsSendMessage = GameAnalyticsSendMessage or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsSendMessage") 42 | -- GameAnalyticsSendMessage:FireAllClients({ 43 | -- Text = m, 44 | -- Font = Enum.Font.Arial, 45 | -- Color = Color3.new(255, 255, 0), 46 | -- FontSize = Enum.FontSize.Size96 47 | -- }) 48 | end 49 | 50 | function logger:e(format) 51 | task.spawn(function() 52 | local m = "Error/GameAnalytics: " .. format 53 | error(m, 0) 54 | -- GameAnalyticsSendMessage = GameAnalyticsSendMessage or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsSendMessage") 55 | -- GameAnalyticsSendMessage:FireAllClients({ 56 | -- Text = m, 57 | -- Font = Enum.Font.Arial, 58 | -- Color = Color3.new(255, 0, 0), 59 | -- FontSize = Enum.FontSize.Size96 60 | -- }) 61 | end) 62 | end 63 | 64 | function logger:d(format) 65 | if not self._debugEnabled then 66 | return 67 | end 68 | 69 | local m = "Debug/GameAnalytics: " .. format 70 | print(m) 71 | -- GameAnalyticsSendMessage = GameAnalyticsSendMessage or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsSendMessage") 72 | -- GameAnalyticsSendMessage:FireAllClients({ 73 | -- Text = m, 74 | -- Font = Enum.Font.Arial, 75 | -- Color = Color3.new(255, 255, 255), 76 | -- FontSize = Enum.FontSize.Size96 77 | -- }) 78 | end 79 | 80 | function logger:ii(format) 81 | if not self._infoLogAdvancedEnabled then 82 | return 83 | end 84 | 85 | local m = "Verbose/GameAnalytics: " .. format 86 | print(m) 87 | -- GameAnalyticsSendMessage = GameAnalyticsSendMessage or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsSendMessage") 88 | -- GameAnalyticsSendMessage:FireAllClients({ 89 | -- Text = m, 90 | -- Font = Enum.Font.Arial, 91 | -- Color = Color3.new(255, 255, 255), 92 | -- FontSize = Enum.FontSize.Size96 93 | -- }) 94 | end 95 | 96 | return logger 97 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Postie.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | Postie 1.1.0 by BenSBk 3 | Depends on: 4 | - The Roblox API 5 | - A RemoteEvent named Sent 6 | - A RemoteEvent named Received 7 | 8 | Postie is a safe alternative to RemoteFunctions with a time-out. 9 | 10 | Postie.invokeClient( // yields, server-side 11 | player: Player, 12 | id: string, 13 | timeOut: number, 14 | ...data: any 15 | ) => didRespond: boolean, ...response: any 16 | 17 | Invoke player with sent data. Invocation identified by id. Yield until 18 | timeOut (given in seconds) is reached and return false, or a response is 19 | received back from the client and return true plus the data returned 20 | from the client. If the invocation reaches the client, but the client 21 | doesn't have a corresponding callback, return before timeOut regardless 22 | but return false. 23 | 24 | Postie.invokeServer( // yields, client-side 25 | id: string, 26 | timeOut: number, 27 | ...data: any 28 | ) => didRespond: boolean, ...response: any 29 | 30 | Invoke the server with sent data. Invocation identified by id. Yield 31 | until timeOut (given in seconds) is reached and return false, or a 32 | response is received back from the server and return true plus the data 33 | returned from the server. If the invocation reaches the server, but the 34 | server doesn't have a corresponding callback, return before timeOut 35 | regardless but return false. 36 | 37 | Postie.setCallback( 38 | id: string, 39 | callback?: (...data: any) -> ...response: any 40 | ) 41 | 42 | Set the callback that is invoked when an invocation identified by id is 43 | sent. Data sent with the invocation are passed to the callback. If on 44 | the server, the player who invoked is implicitly received as the first 45 | argument. 46 | 47 | Postie.getCallback( 48 | id: string 49 | ) => callback?: (...data: any) -> ...response: any 50 | 51 | Return the callback corresponding with id. 52 | ]] 53 | 54 | local HttpService = game:GetService("HttpService") 55 | local RunService = game:GetService("RunService") 56 | local replicatedStorage = game:GetService("ReplicatedStorage") 57 | 58 | if not replicatedStorage:FindFirstChild("PostieSent") then 59 | --Create 60 | local f = Instance.new("RemoteEvent") 61 | f.Name = "PostieSent" 62 | f.Parent = replicatedStorage 63 | end 64 | 65 | if not replicatedStorage:FindFirstChild("PostieReceived") then 66 | --Create 67 | local f = Instance.new("RemoteEvent") 68 | f.Name = "PostieReceived" 69 | f.Parent = replicatedStorage 70 | end 71 | 72 | local sent = replicatedStorage.PostieSent -- RemoteEvent 73 | local received = replicatedStorage.PostieReceived -- RemoteEvent 74 | 75 | local isServer = RunService:IsServer() 76 | local callbackById = {} 77 | local listenerByUuid = {} 78 | 79 | local Postie = {} 80 | 81 | function Postie.invokeClient(id: string, player: Player, timeOut: number, ...: any): (boolean, ...any) 82 | assert(isServer, "Postie.invokeClient can only be called from the server") 83 | 84 | local thread = coroutine.running() 85 | local isResumed = false 86 | local uuid = HttpService:GenerateGUID(false) 87 | 88 | -- We await a signal from the client. 89 | listenerByUuid[uuid] = function(playerWhoFired, didInvokeCallback, ...) 90 | if playerWhoFired ~= player then 91 | -- The client lied about the UUID. 92 | return 93 | end 94 | isResumed = true 95 | listenerByUuid[uuid] = nil 96 | if didInvokeCallback then 97 | task.spawn(thread, true, ...) 98 | else 99 | task.spawn(thread, false) 100 | end 101 | end 102 | 103 | -- We await the time-out. 104 | task.delay(timeOut, function() 105 | if isResumed then 106 | return 107 | end 108 | listenerByUuid[uuid] = nil 109 | task.spawn(thread, false) 110 | end) 111 | 112 | -- Finally, we send the signal to the client and await either the client's 113 | -- response or the time-out. 114 | sent:FireClient(player, id, uuid, ...) 115 | return coroutine.yield() 116 | end 117 | 118 | function Postie.invokeServer(id: string, timeOut: number, ...: any): (boolean, ...any) 119 | assert(not isServer, "Postie.invokeServer can only be called from the client") 120 | 121 | local thread = coroutine.running() 122 | local isResumed = false 123 | local uuid = HttpService:GenerateGUID(false) 124 | 125 | -- We await a signal from the client. 126 | listenerByUuid[uuid] = function(didInvokeCallback, ...) 127 | isResumed = true 128 | listenerByUuid[uuid] = nil 129 | if didInvokeCallback then 130 | task.spawn(thread, true, ...) 131 | else 132 | task.spawn(thread, false) 133 | end 134 | end 135 | 136 | -- We await the time-out. 137 | task.delay(timeOut, function() 138 | if isResumed then 139 | return 140 | end 141 | listenerByUuid[uuid] = nil 142 | task.spawn(thread, false) 143 | end) 144 | 145 | -- Finally, we send the signal to the client and await either the client's 146 | -- response or the time-out. 147 | sent:FireServer(id, uuid, ...) 148 | return coroutine.yield() 149 | end 150 | 151 | function Postie.setCallback(id: string, callback: ((...any) -> ...any)?) 152 | callbackById[id] = callback 153 | end 154 | 155 | function Postie.getCallback(id: string): ((...any) -> ...any)? 156 | return callbackById[id] 157 | end 158 | 159 | if isServer then 160 | -- We handle responses received from the client. 161 | received.OnServerEvent:Connect(function(player, uuid, didInvokeCallback, ...) 162 | local listener = listenerByUuid[uuid] 163 | if not listener then 164 | return 165 | end 166 | listener(player, didInvokeCallback, ...) 167 | end) 168 | 169 | -- We handle requests sent by the client. 170 | sent.OnServerEvent:Connect(function(player, id, uuid, ...) 171 | local callback = callbackById[id] 172 | if callback then 173 | received:FireClient(player, uuid, true, callback(player, ...)) 174 | else 175 | received:FireClient(player, uuid, false) 176 | end 177 | end) 178 | else 179 | -- We handle responses received from the server. 180 | received.OnClientEvent:Connect(function(uuid, didInvokeCallback, ...) 181 | local listener = listenerByUuid[uuid] 182 | if not listener then 183 | return 184 | end 185 | listener(didInvokeCallback, ...) 186 | end) 187 | 188 | -- We handle requests sent by the server. 189 | sent.OnClientEvent:Connect(function(id, uuid, ...) 190 | local callback = callbackById[id] 191 | if callback then 192 | received:FireServer(uuid, true, callback(...)) 193 | else 194 | received:FireServer(uuid, false) 195 | end 196 | end) 197 | end 198 | 199 | return Postie 200 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/State.lua: -------------------------------------------------------------------------------- 1 | local validation = require(script.Parent.Validation) 2 | local logger = require(script.Parent.Logger) 3 | local http_api = require(script.Parent.HttpApi) 4 | local store = require(script.Parent.Store) 5 | local events = require(script.Parent.Events) 6 | local HTTP = game:GetService("HttpService") 7 | 8 | local state = { 9 | _availableCustomDimensions01 = {}, 10 | _availableCustomDimensions02 = {}, 11 | _availableCustomDimensions03 = {}, 12 | _availableGamepasses = {}, 13 | _enableEventSubmission = true, 14 | Initialized = false, 15 | ReportErrors = true, 16 | UseCustomUserId = false, 17 | AutomaticSendBusinessEvents = true, 18 | ConfigsHash = "", 19 | } 20 | 21 | local GameAnalyticsRemoteConfigs 22 | 23 | local function getClientTsAdjusted(playerId) 24 | local PlayerData = store:GetPlayerDataFromCache(playerId) 25 | if not PlayerData then 26 | return os.time() 27 | end 28 | 29 | local clientTs = os.time() 30 | local clientTsAdjustedInteger = clientTs + PlayerData.ClientServerTimeOffset 31 | if validation:validateClientTs(clientTsAdjustedInteger) then 32 | return clientTsAdjustedInteger 33 | else 34 | return clientTs 35 | end 36 | end 37 | 38 | local function populateConfigurations(player) 39 | local PlayerData = store:GetPlayerDataFromCache(player.UserId) 40 | local sdkConfig = PlayerData.SdkConfig 41 | 42 | if sdkConfig["configs"] then 43 | local configurations = sdkConfig["configs"] 44 | 45 | for _, configuration in pairs(configurations) do 46 | if configuration then 47 | local key = configuration["key"] or "" 48 | local start_ts = configuration["start_ts"] or 0 49 | local end_ts = configuration["end_ts"] or math.huge 50 | local client_ts_adjusted = getClientTsAdjusted(player.UserId) 51 | 52 | if 53 | #key > 0 54 | and configuration["value"] 55 | and client_ts_adjusted > start_ts 56 | and client_ts_adjusted < end_ts 57 | then 58 | PlayerData.Configurations[key] = configuration["value"] 59 | logger:d( 60 | "configuration added: key=" .. configuration["key"] .. ", value=" .. configuration["value"] 61 | ) 62 | end 63 | end 64 | end 65 | end 66 | 67 | logger:i("Remote configs populated") 68 | 69 | PlayerData.RemoteConfigsIsReady = true 70 | GameAnalyticsRemoteConfigs = GameAnalyticsRemoteConfigs 71 | or game:GetService("ReplicatedStorage"):WaitForChild("GameAnalyticsRemoteConfigs") 72 | GameAnalyticsRemoteConfigs:FireClient(player, PlayerData.Configurations) 73 | end 74 | 75 | function state:sessionIsStarted(playerId) 76 | local PlayerData = store:GetPlayerDataFromCache(playerId) 77 | if not PlayerData then 78 | return false 79 | end 80 | 81 | return PlayerData.SessionStart ~= 0 82 | end 83 | 84 | function state:isEnabled(playerId) 85 | local PlayerData = store:GetPlayerDataFromCache(playerId) 86 | if not PlayerData then 87 | return false 88 | elseif not PlayerData.InitAuthorized then 89 | return false 90 | else 91 | return true 92 | end 93 | end 94 | 95 | function state:validateAndFixCurrentDimensions(playerId) 96 | local PlayerData = store:GetPlayerDataFromCache(playerId) 97 | 98 | -- validate that there are no current dimension01 not in list 99 | if not validation:validateDimension(self._availableCustomDimensions01, PlayerData.CurrentCustomDimension01) then 100 | logger:d( 101 | "Invalid dimension01 found in variable. Setting to nil. Invalid dimension: " 102 | .. PlayerData.CurrentCustomDimension01 103 | ) 104 | end 105 | 106 | -- validate that there are no current dimension02 not in list 107 | if not validation:validateDimension(self._availableCustomDimensions02, PlayerData.CurrentCustomDimension02) then 108 | logger:d( 109 | "Invalid dimension02 found in variable. Setting to nil. Invalid dimension: " 110 | .. PlayerData.CurrentCustomDimension02 111 | ) 112 | end 113 | 114 | -- validate that there are no current dimension03 not in list 115 | if not validation:validateDimension(self._availableCustomDimensions03, PlayerData.CurrentCustomDimension03) then 116 | logger:d( 117 | "Invalid dimension03 found in variable. Setting to nil. Invalid dimension: " 118 | .. PlayerData.CurrentCustomDimension03 119 | ) 120 | end 121 | end 122 | 123 | function state:setAvailableCustomDimensions01(availableCustomDimensions) 124 | if not validation:validateCustomDimensions(availableCustomDimensions) then 125 | return 126 | end 127 | 128 | self._availableCustomDimensions01 = availableCustomDimensions 129 | logger:i("Set available custom01 dimension values: (" .. table.concat(availableCustomDimensions, ", ") .. ")") 130 | end 131 | 132 | function state:setAvailableCustomDimensions02(availableCustomDimensions) 133 | if not validation:validateCustomDimensions(availableCustomDimensions) then 134 | return 135 | end 136 | 137 | self._availableCustomDimensions02 = availableCustomDimensions 138 | logger:i("Set available custom02 dimension values: (" .. table.concat(availableCustomDimensions, ", ") .. ")") 139 | end 140 | 141 | function state:setAvailableCustomDimensions03(availableCustomDimensions) 142 | if not validation:validateCustomDimensions(availableCustomDimensions) then 143 | return 144 | end 145 | 146 | self._availableCustomDimensions03 = availableCustomDimensions 147 | logger:i("Set available custom03 dimension values: (" .. table.concat(availableCustomDimensions, ", ") .. ")") 148 | end 149 | 150 | function state:setAvailableGamepasses(availableGamepasses) 151 | self._availableGamepasses = availableGamepasses 152 | logger:i("Set available game passes: (" .. table.concat(availableGamepasses, ", ") .. ")") 153 | end 154 | 155 | function state:setEventSubmission(flag) 156 | self._enableEventSubmission = flag 157 | end 158 | 159 | function state:isEventSubmissionEnabled() 160 | return self._enableEventSubmission 161 | end 162 | 163 | function state:setCustomDimension01(playerId, dimension) 164 | local PlayerData = store:GetPlayerDataFromCache(playerId) 165 | PlayerData.CurrentCustomDimension01 = dimension 166 | end 167 | 168 | function state:setCustomDimension02(playerId, dimension) 169 | local PlayerData = store:GetPlayerDataFromCache(playerId) 170 | PlayerData.CurrentCustomDimension02 = dimension 171 | end 172 | 173 | function state:setCustomDimension03(playerId, dimension) 174 | local PlayerData = store:GetPlayerDataFromCache(playerId) 175 | PlayerData.CurrentCustomDimension03 = dimension 176 | end 177 | 178 | function state:startNewSession(player, teleportData, customFields) 179 | if state:isEventSubmissionEnabled() and teleportData == nil then 180 | logger:i("Starting a new session.") 181 | end 182 | 183 | local PlayerData = store:GetPlayerDataFromCache(player.UserId) 184 | 185 | -- make sure the current custom dimensions are valid 186 | state:validateAndFixCurrentDimensions(player.UserId) 187 | 188 | local initResult = http_api:initRequest(events.GameKey, events.SecretKey, events.Build, PlayerData, player.UserId) 189 | local statusCode = initResult.statusCode 190 | local responseBody = initResult.body 191 | 192 | if 193 | (statusCode == http_api.EGAHTTPApiResponse.Ok or statusCode == http_api.EGAHTTPApiResponse.Created) 194 | and responseBody 195 | then 196 | -- set the time offset - how many seconds the local time is different from servertime 197 | local timeOffsetSeconds = 0 198 | local serverTs = responseBody["server_ts"] or -1 199 | if serverTs > 0 then 200 | local clientTs = os.time() 201 | timeOffsetSeconds = serverTs - clientTs 202 | end 203 | 204 | responseBody["time_offset"] = timeOffsetSeconds 205 | 206 | if not (statusCode == http_api.EGAHTTPApiResponse.Created) then 207 | local sdkConfig = PlayerData.SdkConfig 208 | 209 | if sdkConfig["configs"] then 210 | responseBody["configs"] = sdkConfig["configs"] 211 | end 212 | 213 | if sdkConfig["ab_id"] then 214 | responseBody["ab_id"] = sdkConfig["ab_id"] 215 | end 216 | 217 | if sdkConfig["ab_variant_id"] then 218 | responseBody["ab_variant_id"] = sdkConfig["ab_variant_id"] 219 | end 220 | end 221 | 222 | PlayerData.SdkConfig = responseBody 223 | PlayerData.InitAuthorized = true 224 | elseif statusCode == http_api.EGAHTTPApiResponse.Unauthorized then 225 | logger:w("Initialize SDK failed - Unauthorized") 226 | PlayerData.InitAuthorized = false 227 | else 228 | -- log the status if no connection 229 | if 230 | statusCode == http_api.EGAHTTPApiResponse.NoResponse 231 | or statusCode == http_api.EGAHTTPApiResponse.RequestTimeout 232 | then 233 | logger:i("Init call (session start) failed - no response. Could be offline or timeout.") 234 | elseif 235 | statusCode == http_api.EGAHTTPApiResponse.BadResponse 236 | or statusCode == http_api.EGAHTTPApiResponse.JsonEncodeFailed 237 | or statusCode == http_api.EGAHTTPApiResponse.JsonDecodeFailed 238 | then 239 | logger:i("Init call (session start) failed - bad response. Could be bad response from proxy or GA servers.") 240 | elseif 241 | statusCode == http_api.EGAHTTPApiResponse.BadRequest 242 | or statusCode == http_api.EGAHTTPApiResponse.UnknownResponseCode 243 | then 244 | logger:i("Init call (session start) failed - bad request or unknown response.") 245 | end 246 | 247 | PlayerData.InitAuthorized = true 248 | end 249 | 250 | -- set offset in state (memory) from current config (config could be from cache etc.) 251 | PlayerData.ClientServerTimeOffset = PlayerData.SdkConfig["time_offset"] or 0 252 | PlayerData.ConfigsHash = PlayerData.SdkConfig["configs_hash"] or "" 253 | PlayerData.AbId = PlayerData.SdkConfig["ab_id"] or "" 254 | PlayerData.AbVariantId = PlayerData.SdkConfig["ab_variant_id"] or "" 255 | 256 | -- populate configurations 257 | populateConfigurations(player) 258 | 259 | if not state:isEnabled(player.UserId) then 260 | logger:w("Could not start session: SDK is disabled.") 261 | return 262 | end 263 | 264 | if teleportData then 265 | PlayerData.SessionID = teleportData.SessionID 266 | PlayerData.SessionStart = teleportData.SessionStart 267 | else 268 | PlayerData.SessionID = string.lower(HTTP:GenerateGUID(false)) 269 | PlayerData.SessionStart = getClientTsAdjusted(player.UserId) 270 | end 271 | 272 | if state:isEventSubmissionEnabled() then 273 | events:addSessionStartEvent(player.UserId, teleportData, customFields) 274 | end 275 | end 276 | 277 | function state:endSession(playerId, customFields) 278 | if state.Initialized and state:isEventSubmissionEnabled() then 279 | logger:i("Ending session.") 280 | if state:isEnabled(playerId) and state:sessionIsStarted(playerId) then 281 | events:addSessionEndEvent(playerId, customFields) 282 | store.PlayerCache[playerId] = nil 283 | end 284 | end 285 | end 286 | 287 | function state:getRemoteConfigsStringValue(playerId, key, defaultValue) 288 | local PlayerData = store:GetPlayerDataFromCache(playerId) 289 | return PlayerData.Configurations[key] or defaultValue 290 | end 291 | 292 | function state:isRemoteConfigsReady(playerId) 293 | local PlayerData = store:GetPlayerDataFromCache(playerId) 294 | return PlayerData.RemoteConfigsIsReady 295 | end 296 | 297 | function state:getRemoteConfigsContentAsString(playerId) 298 | local PlayerData = store:GetPlayerDataFromCache(playerId) 299 | return HTTP:JSONEncode(PlayerData.Configurations) 300 | end 301 | 302 | return state 303 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Store/DataStoreQueue.lua: -------------------------------------------------------------------------------- 1 | local DataStoreManager = {} 2 | DataStoreManager.QR = true 3 | DataStoreManager.Queue = {} 4 | DataStoreManager.Process = 0 5 | local LastRequest = {} 6 | 7 | task.spawn(function() 8 | while DataStoreManager.QR do 9 | task.wait() 10 | if #DataStoreManager.Queue > 0 then 11 | local Request = DataStoreManager.Queue[1] 12 | table.remove(DataStoreManager.Queue, 1) 13 | if not LastRequest[Request.Key] then 14 | LastRequest[Request.Key] = 0 15 | end 16 | 17 | DataStoreManager.Process += 1 18 | local remain = (Request.Delay + LastRequest[Request.Key]) - DateTime.now().UnixTimestamp 19 | if remain <= 0 then 20 | remain = 0 21 | end 22 | task.delay(remain, function() 23 | local Success, Error, ds 24 | repeat 25 | LastRequest[Request.Key] = DateTime.now().UnixTimestamp 26 | Success, Error, ds = pcall(Request.Func) 27 | 28 | if not Success then 29 | warn(Error) 30 | end 31 | if Success and Error then 32 | break 33 | end 34 | if not Request.Delay then 35 | break 36 | end 37 | task.wait(Request.Delay) 38 | until Success and Error 39 | Request.Event:Fire(Success, Error, ds) 40 | DataStoreManager.Process -= 1 41 | LastRequest[Request.Key] = DateTime.now().UnixTimestamp 42 | end) 43 | end 44 | end 45 | end) 46 | 47 | function DataStoreManager.AddRequest(Key, Request, Delay) 48 | local FinishedEvent = Instance.new("BindableEvent") 49 | table.insert(DataStoreManager.Queue, { 50 | Key = Key, 51 | Delay = Delay, 52 | Func = Request, 53 | Event = FinishedEvent, 54 | }) 55 | local Success, ValOrErr, ds = FinishedEvent.Event:Wait() 56 | return Success, ValOrErr, ds 57 | end 58 | 59 | function DataStoreManager.RemoveKey(Key) 60 | LastRequest[Key] = nil 61 | end 62 | return DataStoreManager 63 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Store/init.lua: -------------------------------------------------------------------------------- 1 | local DS = game:GetService("DataStoreService") 2 | local RunService = game:GetService("RunService") 3 | local DSQ = require(script.DataStoreQueue) 4 | 5 | local store = { 6 | PlayerDS = RunService:IsStudio() and {} or DS:GetDataStore("GA_PlayerDS_1.0.0"), 7 | AutoSaveData = 180, --Set to 0 to disable 8 | BasePlayerData = { 9 | Sessions = 0, 10 | Transactions = 0, 11 | ProgressionTries = {}, 12 | CurrentCustomDimension01 = "", 13 | CurrentCustomDimension02 = "", 14 | CurrentCustomDimension03 = "", 15 | ConfigsHash = "", 16 | AbId = "", 17 | AbVariantId = "", 18 | InitAuthorized = false, 19 | SdkConfig = {}, 20 | ClientServerTimeOffset = 0, 21 | Configurations = {}, 22 | RemoteConfigsIsReady = false, 23 | PlayerTeleporting = false, 24 | OwnedGamepasses = nil, --nil means a completely new player. {} means player with no game passes 25 | CountryCode = "", 26 | CustomUserId = "", 27 | }, 28 | 29 | DataToSave = { 30 | "Sessions", 31 | "Transactions", 32 | "ProgressionTries", 33 | "CurrentCustomDimension01", 34 | "CurrentCustomDimension02", 35 | "CurrentCustomDimension03", 36 | "OwnedGamepasses", 37 | }, 38 | 39 | --Cache 40 | PlayerCache = {}, 41 | EventsQueue = {}, 42 | DataStoreQueue = DSQ, 43 | } 44 | 45 | function store:GetPlayerData(Player) 46 | local key = Player.UserId 47 | local success, PlayerData = DSQ.AddRequest(key, function() 48 | return RunService:IsStudio() and {} or (store.PlayerDS:GetAsync(key) or {}) 49 | end, 7) -- Add to a queue with 7s delay between each request 50 | 51 | if not success then 52 | PlayerData = {} 53 | end 54 | return PlayerData 55 | end 56 | 57 | function store:GetPlayerDataFromCache(userId) 58 | local playerData = store.PlayerCache[tonumber(userId)] 59 | if playerData then 60 | return playerData 61 | end 62 | playerData = store.PlayerCache[tostring(userId)] 63 | return playerData 64 | end 65 | 66 | function store:GetErrorDataStore(scope) 67 | local ErrorDS 68 | local success = pcall(function() 69 | ErrorDS = RunService:IsStudio() and {} or DS:GetDataStore("GA_ErrorDS_1.0.0", scope) 70 | end) 71 | 72 | if not success then 73 | ErrorDS = {} 74 | end 75 | 76 | return ErrorDS 77 | end 78 | 79 | function store:SavePlayerData(Player) 80 | --Variables 81 | local PlayerData = store:GetPlayerDataFromCache(Player.UserId) 82 | local SavePlayerData = {} 83 | 84 | if not PlayerData then 85 | return 86 | end 87 | 88 | --Fill 89 | for _, key in pairs(store.DataToSave) do 90 | SavePlayerData[key] = PlayerData[key] 91 | end 92 | 93 | --Save 94 | local key = Player.UserId 95 | if not RunService:IsStudio() then 96 | DSQ.AddRequest(key, function() 97 | return store.PlayerDS:SetAsync(key, SavePlayerData) 98 | end, 7) 99 | end 100 | end 101 | 102 | function store:IncrementErrorCount(ErrorDS, ErrorKey, step) 103 | if not ErrorKey then 104 | return 105 | end 106 | 107 | local count = 0 108 | --Increment count 109 | if not RunService:IsStudio() then 110 | --Increment count 111 | _, count = DSQ.AddRequest(ErrorKey, function() 112 | return ErrorDS:IncrementAsync(ErrorKey, step) 113 | end, 7) 114 | end 115 | return count 116 | end 117 | return store 118 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Threading.lua: -------------------------------------------------------------------------------- 1 | local threading = { 2 | _canSafelyClose = true, 3 | _endThread = false, 4 | _isRunning = false, 5 | _blocks = {}, 6 | _scheduledBlock = nil, 7 | _hasScheduledBlockRun = true, 8 | } 9 | 10 | local logger = require(script.Parent.Logger) 11 | local RunService = game:GetService("RunService") 12 | 13 | local function getScheduledBlock() 14 | local now = tick() 15 | 16 | if 17 | not threading._hasScheduledBlockRun 18 | and threading._scheduledBlock ~= nil 19 | and threading._scheduledBlock.deadline <= now 20 | then 21 | threading._hasScheduledBlockRun = true 22 | return threading._scheduledBlock 23 | else 24 | return nil 25 | end 26 | end 27 | 28 | local function run() 29 | task.spawn(function() 30 | logger:d("Starting GA thread") 31 | 32 | while not threading._endThread do 33 | threading._canSafelyClose = false 34 | 35 | if #threading._blocks ~= 0 then 36 | for _, b in pairs(threading._blocks) do 37 | local s, e = pcall(b.block) 38 | if not s then 39 | logger:e(e) 40 | end 41 | end 42 | 43 | threading._blocks = {} 44 | end 45 | 46 | local timedBlock = getScheduledBlock() 47 | if timedBlock ~= nil then 48 | local s, e = pcall(timedBlock.block) 49 | if not s then 50 | logger:e(e) 51 | end 52 | end 53 | 54 | threading._canSafelyClose = true 55 | task.wait(1) 56 | end 57 | 58 | logger:d("GA thread stopped") 59 | end) 60 | 61 | --Safely Close 62 | game:BindToClose(function() 63 | -- waiting bug fix to work inside studio 64 | if RunService:IsStudio() then 65 | return 66 | end 67 | 68 | --Give game.Players.PlayerRemoving time to to its thang 69 | task.wait(1) 70 | 71 | --Delay 72 | if not threading._canSafelyClose then 73 | repeat 74 | task.wait() 75 | until threading._canSafelyClose 76 | end 77 | 78 | task.wait(3) 79 | end) 80 | end 81 | 82 | function threading:scheduleTimer(interval, callback) 83 | if self._endThread then 84 | return 85 | end 86 | 87 | if not self._isRunning then 88 | self._isRunning = true 89 | run() 90 | end 91 | 92 | local timedBlock = { 93 | block = callback, 94 | deadline = tick() + interval, 95 | } 96 | 97 | if self._hasScheduledBlockRun then 98 | self._scheduledBlock = timedBlock 99 | self._hasScheduledBlockRun = false 100 | end 101 | end 102 | 103 | function threading:performTaskOnGAThread(callback) 104 | if self._endThread then 105 | return 106 | end 107 | 108 | if not self._isRunning then 109 | self._isRunning = true 110 | run() 111 | end 112 | 113 | local timedBlock = { 114 | block = callback, 115 | } 116 | 117 | self._blocks[#self._blocks + 1] = timedBlock 118 | end 119 | 120 | function threading:stopThread() 121 | self._endThread = true 122 | end 123 | 124 | return threading 125 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Types.lua: -------------------------------------------------------------------------------- 1 | type EventOptions = { 2 | customFields: { [string]: string }?, 3 | } 4 | 5 | export type BusinessEventOptions = EventOptions & { 6 | amount: number, 7 | itemType: string, 8 | itemId: string, 9 | cartType: string?, 10 | } 11 | 12 | export type ResourceEventOptions = EventOptions & { 13 | flowType: number, 14 | currency: string, 15 | amount: number, 16 | itemType: string, 17 | itemId: string, 18 | } 19 | 20 | export type ProgressionEventOptions = EventOptions & { 21 | progressionStatus: number, 22 | progression01: string, 23 | progression02: string?, 24 | progression03: string?, 25 | score: number?, 26 | } 27 | 28 | export type DesignEventOptions = EventOptions & { 29 | eventId: string, 30 | value: number?, 31 | } 32 | 33 | export type ErrorEventOptions = EventOptions & { 34 | message: string, 35 | severity: number, 36 | } 37 | 38 | export type CustomDimension = string 39 | 40 | export type ProductInfo = { 41 | Name: string, 42 | PriceInRobux: number, 43 | } 44 | 45 | export type ProcessReceiptInfo = { 46 | ProductId: number, 47 | PlayerId: number, 48 | CurrencySpent: number, 49 | } 50 | 51 | export type TeleportData = { [string]: any } 52 | export type RemoteConfigs = { [string]: any } 53 | 54 | export type GameAnalyticsOptions = { 55 | enableInfoLog: boolean?, 56 | enableVerboseLog: boolean?, 57 | availableCustomDimensions01: { CustomDimension }?, 58 | availableCustomDimensions02: { CustomDimension }?, 59 | availableCustomDimensions03: { CustomDimension }?, 60 | availableResourceCurrencies: { string }?, 61 | availableResourceItemTypes: { string }?, 62 | build: string?, 63 | availableGamepasses: { string }?, 64 | enableDebugLog: boolean?, 65 | automaticSendBusinessEvents: boolean?, 66 | reportErrors: boolean?, 67 | useCustomUserId: boolean?, 68 | gameKey: string?, 69 | secretKey: string?, 70 | } 71 | 72 | return {} 73 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Utilities.lua: -------------------------------------------------------------------------------- 1 | local utilities = {} 2 | 3 | function utilities:isStringNullOrEmpty(s) 4 | return (not s) or #s == 0 5 | end 6 | 7 | function utilities:stringArrayContainsString(array, search) 8 | if #array == 0 then 9 | return false 10 | end 11 | 12 | for _, s in ipairs(array) do 13 | if s == search then 14 | return true 15 | end 16 | end 17 | 18 | return false 19 | end 20 | 21 | function utilities:copyTable(t) 22 | local copy = {} 23 | for k, v in pairs(t) do 24 | if typeof(v) == "table" then 25 | copy[k] = self:copyTable(v) 26 | else 27 | copy[k] = v 28 | end 29 | end 30 | return copy 31 | end 32 | 33 | return utilities 34 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Validation.lua: -------------------------------------------------------------------------------- 1 | local validation = {} 2 | 3 | local logger = require(script.Parent.Logger) 4 | local utilities = require(script.Parent.Utilities) 5 | 6 | function validation:validateCustomDimensions(customDimensions) 7 | return validation:validateArrayOfStrings(20, 32, false, "custom dimensions", customDimensions) 8 | end 9 | 10 | function validation:validateDimension(dimensions, dimension) 11 | -- allow nil 12 | if utilities:isStringNullOrEmpty(dimension) then 13 | return true 14 | end 15 | 16 | if not utilities:stringArrayContainsString(dimensions, dimension) then 17 | return false 18 | end 19 | 20 | return true 21 | end 22 | 23 | function validation:validateResourceCurrencies(resourceCurrencies) 24 | if not validation:validateArrayOfStrings(20, 64, false, "resource currencies", resourceCurrencies) then 25 | return false 26 | end 27 | 28 | -- validate each string for regex 29 | for _, resourceCurrency in pairs(resourceCurrencies) do 30 | if not string.find(resourceCurrency, "^[A-Za-z]+$") then 31 | logger:w( 32 | "resource currencies validation failed: a resource currency can only be A-Z, a-z. String was: " 33 | .. resourceCurrency 34 | ) 35 | return false 36 | end 37 | end 38 | 39 | return true 40 | end 41 | 42 | function validation:validateResourceItemTypes(resourceItemTypes) 43 | if not validation:validateArrayOfStrings(20, 32, false, "resource item types", resourceItemTypes) then 44 | return false 45 | end 46 | 47 | -- validate each string for regex 48 | for _, resourceItemType in pairs(resourceItemTypes) do 49 | if not validation:validateEventPartCharacters(resourceItemType) then 50 | logger:w( 51 | "resource item types validation failed: a resource item type cannot contain other characters than A-z, 0-9, -_., ()!?. String was: " 52 | .. resourceItemType 53 | ) 54 | return false 55 | end 56 | end 57 | 58 | return true 59 | end 60 | 61 | function validation:validateEventPartCharacters(eventPart) 62 | if not string.find(eventPart, "^[A-Za-z0-9%s%-_%.%(%)!%?]+$") then 63 | return false 64 | end 65 | 66 | return true 67 | end 68 | 69 | function validation:validateArrayOfStrings(maxCount, maxStringLength, allowNoValues, logTag, arrayOfStrings) 70 | local arrayTag = logTag 71 | 72 | if not arrayTag then 73 | arrayTag = "Array" 74 | end 75 | 76 | -- use arrayTag to annotate warning log 77 | if not arrayOfStrings then 78 | logger:w(arrayTag .. " validation failed: array cannot be nil.") 79 | return false 80 | end 81 | 82 | -- check if empty 83 | if not allowNoValues and #arrayOfStrings == 0 then 84 | logger:w(arrayTag .. " validation failed: array cannot be empty.") 85 | return false 86 | end 87 | 88 | -- check if exceeding max count 89 | if maxCount > 0 and #arrayOfStrings > maxCount then 90 | logger:w( 91 | arrayTag 92 | .. " validation failed: array cannot exceed " 93 | .. tostring(maxCount) 94 | .. " values. It has " 95 | .. #arrayOfStrings 96 | .. " values." 97 | ) 98 | return false 99 | end 100 | 101 | -- validate each string 102 | for _, arrayString in ipairs(arrayOfStrings) do 103 | local stringLength = 0 104 | if arrayString then 105 | stringLength = #arrayString 106 | end 107 | 108 | -- check if empty (not allowed) 109 | if stringLength == 0 then 110 | logger:w(arrayTag .. " validation failed: contained an empty string.") 111 | return false 112 | end 113 | 114 | -- check if exceeding max length 115 | if maxStringLength > 0 and stringLength > maxStringLength then 116 | logger:w( 117 | arrayTag 118 | .. " validation failed: a string exceeded max allowed length (which is: " 119 | .. tostring(maxStringLength) 120 | .. "). String was: " 121 | .. arrayString 122 | ) 123 | return false 124 | end 125 | end 126 | 127 | return true 128 | end 129 | 130 | function validation:validateBuild(build) 131 | if not validation:validateShortString(build, false) then 132 | return false 133 | end 134 | 135 | return true 136 | end 137 | 138 | function validation:validateShortString(shortString, canBeEmpty) 139 | -- String is allowed to be empty or nil 140 | if canBeEmpty and utilities:isStringNullOrEmpty(shortString) then 141 | return true 142 | end 143 | 144 | if utilities:isStringNullOrEmpty(shortString) or #shortString > 32 then 145 | return false 146 | end 147 | 148 | return true 149 | end 150 | 151 | function validation:validateKeys(gameKey, secretKey) 152 | if string.find(gameKey, "^[A-Za-z0-9]+$") and #gameKey == 32 then 153 | if string.find(secretKey, "^[A-Za-z0-9]+$") and #secretKey == 40 then 154 | return true 155 | end 156 | end 157 | 158 | return false 159 | end 160 | 161 | function validation:validateAndCleanInitRequestResponse(initResponse, configsCreated) 162 | -- make sure we have a valid dict 163 | if not initResponse then 164 | logger:w("validateInitRequestResponse failed - no response dictionary.") 165 | return nil 166 | end 167 | 168 | local validatedDict = {} 169 | 170 | -- validate server_ts 171 | local serverTsNumber = initResponse["server_ts"] or -1 172 | if serverTsNumber > 0 then 173 | validatedDict["server_ts"] = serverTsNumber 174 | end 175 | 176 | if configsCreated then 177 | validatedDict["configs"] = initResponse["configs"] or {} 178 | validatedDict["ab_id"] = initResponse["ab_id"] or "" 179 | validatedDict["ab_variant_id"] = initResponse["ab_variant_id"] or "" 180 | end 181 | 182 | return validatedDict 183 | end 184 | 185 | function validation:validateClientTs(clientTs) 186 | if clientTs < 1000000000 or clientTs > 9999999999 then 187 | return false 188 | end 189 | 190 | return true 191 | end 192 | 193 | function validation:validateCurrency(currency) 194 | if utilities:isStringNullOrEmpty(currency) then 195 | return false 196 | end 197 | 198 | if string.find(currency, "^[A-Z]+$") and #currency == 3 then 199 | return true 200 | end 201 | 202 | return false 203 | end 204 | 205 | function validation:validateEventPartLength(eventPart, allowNull) 206 | if allowNull and utilities:isStringNullOrEmpty(eventPart) then 207 | return true 208 | end 209 | 210 | if utilities:isStringNullOrEmpty(eventPart) then 211 | return false 212 | end 213 | 214 | if #eventPart == 0 or #eventPart > 64 then 215 | return false 216 | end 217 | return true 218 | end 219 | 220 | function validation:validateBusinessEvent(currency, amount, cartType, itemType, itemId) 221 | -- validate currency 222 | if not validation:validateCurrency(currency) then 223 | logger:w( 224 | "Validation fail - business event - currency: Cannot be (null) and need to be A-Z, 3 characters and in the standard at openexchangerates.org. Failed currency: " 225 | .. currency 226 | ) 227 | return false 228 | end 229 | 230 | if amount < 0 then 231 | logger:w("Validation fail - business event - amount: Cannot be less then 0. Failed amount: " .. amount) 232 | return false 233 | end 234 | 235 | -- validate cartType 236 | if not validation:validateShortString(cartType, true) then 237 | logger:w("Validation fail - business event - cartType. Cannot be above 32 length. String: " .. cartType) 238 | return false 239 | end 240 | 241 | -- validate itemType length 242 | if not validation:validateEventPartLength(itemType, false) then 243 | logger:w( 244 | "Validation fail - business event - itemType: Cannot be (null), empty or above 64 characters. String: " 245 | .. itemType 246 | ) 247 | return false 248 | end 249 | 250 | -- validate itemType chars 251 | if not validation:validateEventPartCharacters(itemType) then 252 | logger:w( 253 | "Validation fail - business event - itemType: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 254 | .. itemType 255 | ) 256 | return false 257 | end 258 | 259 | -- validate itemId 260 | if not validation:validateEventPartLength(itemId, false) then 261 | logger:w( 262 | "Validation fail - business event - itemId. Cannot be (null), empty or above 64 characters. String: " 263 | .. itemId 264 | ) 265 | return false 266 | end 267 | 268 | if not validation:validateEventPartCharacters(itemId) then 269 | logger:w( 270 | "Validation fail - business event - itemId: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 271 | .. itemId 272 | ) 273 | return false 274 | end 275 | 276 | return true 277 | end 278 | 279 | function validation:validateResourceEvent( 280 | flowTypeValues, 281 | flowType, 282 | currency, 283 | amount, 284 | itemType, 285 | itemId, 286 | currencies, 287 | itemTypes 288 | ) 289 | if flowType ~= flowTypeValues.Source and flowType ~= flowTypeValues.Sink then 290 | logger:w("Validation fail - resource event - flowType: Invalid flow type " .. tostring(flowType)) 291 | return false 292 | end 293 | 294 | if utilities:isStringNullOrEmpty(currency) then 295 | logger:w("Validation fail - resource event - currency: Cannot be (null)") 296 | return false 297 | end 298 | 299 | if not utilities:stringArrayContainsString(currencies, currency) then 300 | logger:w( 301 | "Validation fail - resource event - currency: Not found in list of pre-defined available resource currencies. String: " 302 | .. currency 303 | ) 304 | return false 305 | end 306 | 307 | if not (amount > 0) then 308 | logger:w( 309 | "Validation fail - resource event - amount: Float amount cannot be 0 or negative. Value: " 310 | .. tostring(amount) 311 | ) 312 | return false 313 | end 314 | 315 | if utilities:isStringNullOrEmpty(itemType) then 316 | logger:w("Validation fail - resource event - itemType: Cannot be (null)") 317 | return false 318 | end 319 | 320 | if not validation:validateEventPartLength(itemType, false) then 321 | logger:w( 322 | "Validation fail - resource event - itemType: Cannot be (null), empty or above 64 characters. String: " 323 | .. itemType 324 | ) 325 | return false 326 | end 327 | 328 | if not validation:validateEventPartCharacters(itemType) then 329 | logger:w( 330 | "Validation fail - resource event - itemType: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 331 | .. itemType 332 | ) 333 | return false 334 | end 335 | 336 | if not utilities:stringArrayContainsString(itemTypes, itemType) then 337 | logger:w( 338 | "Validation fail - resource event - itemType: Not found in list of pre-defined available resource itemTypes. String: " 339 | .. itemType 340 | ) 341 | return false 342 | end 343 | 344 | if not validation:validateEventPartLength(itemId, false) then 345 | logger:w( 346 | "Validation fail - resource event - itemId: Cannot be (null), empty or above 64 characters. String: " 347 | .. itemId 348 | ) 349 | return false 350 | end 351 | 352 | if not validation:validateEventPartCharacters(itemId) then 353 | logger:w( 354 | "Validation fail - resource event - itemId: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 355 | .. itemId 356 | ) 357 | return false 358 | end 359 | 360 | return true 361 | end 362 | 363 | function validation:validateProgressionEvent( 364 | progressionStatusValues, 365 | progressionStatus, 366 | progression01, 367 | progression02, 368 | progression03 369 | ) 370 | if 371 | progressionStatus ~= progressionStatusValues.Start 372 | and progressionStatus ~= progressionStatusValues.Complete 373 | and progressionStatus ~= progressionStatusValues.Fail 374 | then 375 | logger:w("Validation fail - progression event: Invalid progression status " .. tostring(progressionStatus)) 376 | return false 377 | end 378 | 379 | -- Make sure progressions are defined as either 01, 01+02 or 01+02+03 380 | if 381 | not utilities:isStringNullOrEmpty(progression03) 382 | and not (not utilities:isStringNullOrEmpty(progression02) or utilities:isStringNullOrEmpty(progression01)) 383 | then 384 | logger:w( 385 | "Validation fail - progression event: 03 found but 01+02 are invalid. Progression must be set as either 01, 01+02 or 01+02+03." 386 | ) 387 | return false 388 | elseif not utilities:isStringNullOrEmpty(progression02) and utilities:isStringNullOrEmpty(progression01) then 389 | logger:w( 390 | "Validation fail - progression event: 02 found but not 01. Progression must be set as either 01, 01+02 or 01+02+03" 391 | ) 392 | return false 393 | elseif utilities:isStringNullOrEmpty(progression01) then 394 | logger:w( 395 | "Validation fail - progression event: progression01 not valid. Progressions must be set as either 01, 01+02 or 01+02+03" 396 | ) 397 | return false 398 | end 399 | 400 | -- progression01 (required) 401 | if not validation:validateEventPartLength(progression01, false) then 402 | logger:w( 403 | "Validation fail - progression event - progression01: Cannot be (null), empty or above 64 characters. String: " 404 | .. progression01 405 | ) 406 | return false 407 | end 408 | 409 | if not validation:validateEventPartCharacters(progression01) then 410 | logger:w( 411 | "Validation fail - progression event - progression01: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 412 | .. progression01 413 | ) 414 | return false 415 | end 416 | 417 | -- progression02 418 | if not utilities:isStringNullOrEmpty(progression02) then 419 | if not validation:validateEventPartLength(progression02, false) then 420 | logger:w( 421 | "Validation fail - progression event - progression02: Cannot be empty or above 64 characters. String: " 422 | .. progression02 423 | ) 424 | return false 425 | end 426 | 427 | if not validation:validateEventPartCharacters(progression02) then 428 | logger:w( 429 | "Validation fail - progression event - progression02: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 430 | .. progression02 431 | ) 432 | return false 433 | end 434 | end 435 | 436 | -- progression03 437 | if not utilities:isStringNullOrEmpty(progression03) then 438 | if not validation:validateEventPartLength(progression03, false) then 439 | logger:w( 440 | "Validation fail - progression event - progression03: Cannot be empty or above 64 characters. String: " 441 | .. progression03 442 | ) 443 | return false 444 | end 445 | 446 | if not validation:validateEventPartCharacters(progression03) then 447 | logger:w( 448 | "Validation fail - progression event - progression03: Cannot contain other characters than A-z, 0-9, -_., ()!?. String: " 449 | .. progression03 450 | ) 451 | return false 452 | end 453 | end 454 | 455 | return true 456 | end 457 | 458 | function validation:validateEventIdLength(eventId) 459 | if utilities:isStringNullOrEmpty(eventId) then 460 | return false 461 | end 462 | 463 | local count = 0 464 | for s in string.gmatch(eventId, "([^:]+)") do 465 | count = count + 1 466 | if count > 5 or #s > 64 then 467 | return false 468 | end 469 | end 470 | 471 | return true 472 | end 473 | 474 | function validation:validateEventIdCharacters(eventId) 475 | if utilities:isStringNullOrEmpty(eventId) then 476 | return false 477 | end 478 | 479 | local count = 0 480 | for s in string.gmatch(eventId, "([^:]+)") do 481 | count = count + 1 482 | if count > 5 or not string.find(s, "^[A-Za-z0-9%s%-_%.%(%)!%?]+$") then 483 | return false 484 | end 485 | end 486 | 487 | return true 488 | end 489 | 490 | function validation:validateDesignEvent(eventId) 491 | if not validation:validateEventIdLength(eventId) then 492 | logger:w( 493 | "Validation fail - design event - eventId: Cannot be (null) or empty. Only 5 event parts allowed seperated by :. Each part need to be 32 characters or less. String: " 494 | .. eventId 495 | ) 496 | return false 497 | end 498 | 499 | if not validation:validateEventIdCharacters(eventId) then 500 | logger:w( 501 | "Validation fail - design event - eventId: Non valid characters. Only allowed A-z, 0-9, -_., ()!?. String: " 502 | .. eventId 503 | ) 504 | return false 505 | end 506 | 507 | -- value: allow 0, negative and nil (not required) 508 | return true 509 | end 510 | 511 | function validation:validateLongString(longString, canBeEmpty) 512 | -- String is allowed to be empty 513 | if canBeEmpty and utilities:isStringNullOrEmpty(longString) then 514 | return true 515 | end 516 | 517 | if utilities:isStringNullOrEmpty(longString) or #longString > 8192 then 518 | return false 519 | end 520 | 521 | return true 522 | end 523 | 524 | function validation:validateErrorEvent(severityValues, severity, message) 525 | if 526 | severity ~= severityValues.debug 527 | and severity ~= severityValues.info 528 | and severity ~= severityValues.warning 529 | and severity ~= severityValues.error 530 | and severity ~= severityValues.critical 531 | then 532 | logger:w("Validation fail - error event - severity: Severity was unsupported value " .. tostring(severity)) 533 | return false 534 | end 535 | 536 | if not validation:validateLongString(message, true) then 537 | logger:w("Validation fail - error event - message: Message cannot be above 8192 characters.") 538 | return false 539 | end 540 | 541 | return true 542 | end 543 | 544 | return validation 545 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/Version.lua: -------------------------------------------------------------------------------- 1 | local version = { 2 | SdkVersion = "2.2.6" 3 | } 4 | 5 | return version 6 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalytics/init.lua: -------------------------------------------------------------------------------- 1 | local GAResourceFlowType = require(script.GAResourceFlowType) 2 | local GAProgressionStatus = require(script.GAProgressionStatus) 3 | local GAErrorSeverity = require(script.GAErrorSeverity) 4 | 5 | local ga = { 6 | EGAResourceFlowType = GAResourceFlowType, 7 | EGAProgressionStatus = GAProgressionStatus, 8 | EGAErrorSeverity = GAErrorSeverity, 9 | } 10 | 11 | local types = require(script.Types) 12 | local logger = require(script.Logger) 13 | local threading = require(script.Threading) 14 | local state = require(script.State) 15 | local validation = require(script.Validation) 16 | local store = require(script.Store) 17 | local events = require(script.Events) 18 | local utilities = require(script.Utilities) 19 | local Players = game:GetService("Players") 20 | local MKT = game:GetService("MarketplaceService") 21 | local RunService = game:GetService("RunService") 22 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 23 | local LocalizationService = game:GetService("LocalizationService") 24 | local ScriptContext = game:GetService("ScriptContext") 25 | local Postie = require(script.Postie) 26 | local OnPlayerReadyEvent 27 | local ProductCache = {} 28 | local ONE_HOUR_IN_SECONDS = 3600 29 | local MaxErrorsPerHour = 10 30 | local ErrorDS = {} 31 | local errorCountCache = {} 32 | local errorCountCacheKeys = {} 33 | 34 | local InitializationQueue = {} 35 | local InitializationQueueByUserId = {} 36 | 37 | type BusinessEventOptions = types.BusinessEventOptions 38 | type ResourceEventOptions = types.ResourceEventOptions 39 | type ProgressionEventOptions = types.ProgressionEventOptions 40 | type DesignEventOptions = types.DesignEventOptions 41 | type ErrorEventOptions = types.ErrorEventOptions 42 | type CustomDimension = types.CustomDimension 43 | type ProductInfo = types.ProductInfo 44 | type ProcessReceiptInfo = types.ProcessReceiptInfo 45 | type TeleportData = types.TeleportData 46 | type RemoteConfigs = types.RemoteConfigs 47 | type GameAnalyticsOptions = types.GameAnalyticsOptions 48 | 49 | local function addToInitializationQueue(func, ...) 50 | if InitializationQueue ~= nil then 51 | table.insert(InitializationQueue, { 52 | Func = func, 53 | Args = { ... }, 54 | }) 55 | 56 | logger:i("Added event to initialization queue") 57 | else 58 | --This should never happen 59 | logger:w("Initialization queue already cleared.") 60 | end 61 | end 62 | 63 | local function addToInitializationQueueByUserId(userId, func, ...) 64 | if not ga:isPlayerReady(userId) then 65 | if InitializationQueueByUserId[userId] == nil then 66 | InitializationQueueByUserId[userId] = {} 67 | end 68 | 69 | table.insert(InitializationQueueByUserId[userId], { 70 | Func = func, 71 | Args = { ... }, 72 | }) 73 | 74 | logger:i("Added event to player initialization queue") 75 | else 76 | --This should never happen 77 | logger:w("Player initialization queue already cleared.") 78 | end 79 | end 80 | 81 | -- local functions 82 | local function isSdkReady(options) 83 | local playerId = options["playerId"] or nil 84 | local needsInitialized = options["needsInitialized"] or true 85 | local shouldWarn = options["shouldWarn"] or false 86 | local message = options["message"] or "" 87 | 88 | -- Is SDK initialized 89 | if needsInitialized and not state.Initialized then 90 | if shouldWarn then 91 | logger:w(message .. " SDK is not initialized") 92 | end 93 | 94 | return false 95 | end 96 | 97 | -- Is SDK enabled 98 | if needsInitialized and playerId and not state:isEnabled(playerId) then 99 | if shouldWarn then 100 | logger:w(message .. " SDK is disabled") 101 | end 102 | 103 | return false 104 | end 105 | 106 | -- Is session started 107 | if needsInitialized and playerId and not state:sessionIsStarted(playerId) then 108 | if shouldWarn then 109 | logger:w(message .. " Session has not started yet") 110 | end 111 | 112 | return false 113 | end 114 | 115 | return true 116 | end 117 | 118 | function ga:configureAvailableCustomDimensions01(customDimensions: { string }) 119 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 120 | logger:w("Available custom dimensions must be set before SDK is initialized") 121 | return 122 | end 123 | 124 | state:setAvailableCustomDimensions01(customDimensions) 125 | end 126 | 127 | function ga:configureAvailableCustomDimensions02(customDimensions: { string }) 128 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 129 | logger:w("Available custom dimensions must be set before SDK is initialized") 130 | return 131 | end 132 | 133 | state:setAvailableCustomDimensions02(customDimensions) 134 | end 135 | 136 | function ga:configureAvailableCustomDimensions03(customDimensions: { string }) 137 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 138 | logger:w("Available custom dimensions must be set before SDK is initialized") 139 | return 140 | end 141 | 142 | state:setAvailableCustomDimensions03(customDimensions) 143 | end 144 | 145 | function ga:configureAvailableResourceCurrencies(resourceCurrencies: { string }) 146 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 147 | logger:w("Available resource currencies must be set before SDK is initialized") 148 | return 149 | end 150 | 151 | events:setAvailableResourceCurrencies(resourceCurrencies) 152 | end 153 | 154 | function ga:configureAvailableResourceItemTypes(resourceItemTypes: { string }) 155 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 156 | logger:w("Available resource item types must be set before SDK is initialized") 157 | return 158 | end 159 | 160 | events:setAvailableResourceItemTypes(resourceItemTypes) 161 | end 162 | 163 | function ga:configureBuild(build: string) 164 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 165 | logger:w("Build version must be set before SDK is initialized.") 166 | return 167 | end 168 | 169 | events:setBuild(build) 170 | end 171 | 172 | function ga:configureAvailableGamepasses(availableGamepasses: { string }) 173 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 174 | logger:w("Available gamepasses must be set before SDK is initialized.") 175 | return 176 | end 177 | 178 | state:setAvailableGamepasses(availableGamepasses) 179 | end 180 | 181 | function ga:startNewSession(player: Player, gaData) 182 | threading:performTaskOnGAThread(function() 183 | if not state:isEventSubmissionEnabled() then 184 | return 185 | end 186 | 187 | if not state.Initialized then 188 | logger:w("Cannot start new session. SDK is not initialized yet.") 189 | return 190 | end 191 | 192 | state:startNewSession(player, gaData) 193 | end) 194 | end 195 | 196 | function ga:endSession(playerId: number) 197 | threading:performTaskOnGAThread(function() 198 | if not state:isEventSubmissionEnabled() then 199 | return 200 | end 201 | state:endSession(playerId) 202 | end) 203 | end 204 | 205 | function ga:filterForBusinessEvent(text: string) 206 | return string.gsub(text, "[^A-Za-z0-9%s%-_%.%(%)!%?]", "") 207 | end 208 | 209 | function ga:addBusinessEvent(playerId: number | BusinessEventOptions, options: BusinessEventOptions?) 210 | threading:performTaskOnGAThread(function() 211 | if not state:isEventSubmissionEnabled() then 212 | return 213 | end 214 | if 215 | not isSdkReady({ 216 | playerId = playerId, 217 | needsInitialized = true, 218 | shouldWarn = false, 219 | message = "Could not add business event", 220 | }) 221 | then 222 | if playerId then 223 | addToInitializationQueueByUserId(playerId, ga.addBusinessEvent, ga, playerId, options) 224 | else 225 | addToInitializationQueue(ga.addBusinessEvent, ga, playerId, options) 226 | end 227 | return 228 | end 229 | 230 | if not options then 231 | return 232 | end 233 | 234 | -- Send to events 235 | local amount = options["amount"] or 0 236 | local itemType = options["itemType"] or "" 237 | local itemId = options["itemId"] or "" 238 | local cartType = options["cartType"] or "" 239 | local USDSpent = math.floor((amount * 0.7) * 0.35) 240 | local gamepassId = options["gamepassId"] or nil 241 | local customFields = options["customFields"] 242 | 243 | events:addBusinessEvent(playerId, "USD", USDSpent, itemType, itemId, cartType, customFields) 244 | 245 | if itemType == "Gamepass" and cartType ~= "Website" then 246 | local player = Players:GetPlayerByUserId(playerId) 247 | local playerData = store:GetPlayerDataFromCache(playerId) 248 | if not playerData.OwnedGamepasses then 249 | playerData.OwnedGamepasses = {} 250 | end 251 | table.insert(playerData.OwnedGamepasses, gamepassId) 252 | store.PlayerCache[playerId] = playerData 253 | store:SavePlayerData(player) 254 | end 255 | end) 256 | end 257 | 258 | function ga:addResourceEvent(playerId: number | ResourceEventOptions, options: ResourceEventOptions?) 259 | threading:performTaskOnGAThread(function() 260 | if not state:isEventSubmissionEnabled() then 261 | return 262 | end 263 | if 264 | not isSdkReady({ 265 | playerId = playerId, 266 | needsInitialized = true, 267 | shouldWarn = false, 268 | message = "Could not add resource event", 269 | }) 270 | then 271 | if playerId then 272 | addToInitializationQueueByUserId(playerId, ga.addResourceEvent, ga, playerId, options) 273 | else 274 | addToInitializationQueue(ga.addResourceEvent, ga, playerId, options) 275 | end 276 | return 277 | end 278 | 279 | if not options then 280 | return 281 | end 282 | 283 | -- Send to events 284 | local flowType = options["flowType"] or 0 285 | local currency = options["currency"] or "" 286 | local amount = options["amount"] or 0 287 | local itemType = options["itemType"] or "" 288 | local itemId = options["itemId"] or "" 289 | local customFields = options["customFields"] 290 | 291 | events:addResourceEvent(playerId, flowType, currency, amount, itemType, itemId, customFields) 292 | end) 293 | end 294 | 295 | function ga:addProgressionEvent(playerId: number | ProgressionEventOptions, options: ProgressionEventOptions?) 296 | threading:performTaskOnGAThread(function() 297 | if not state:isEventSubmissionEnabled() then 298 | return 299 | end 300 | if 301 | not isSdkReady({ 302 | playerId = playerId, 303 | needsInitialized = true, 304 | shouldWarn = false, 305 | message = "Could not add progression event", 306 | }) 307 | then 308 | if playerId then 309 | addToInitializationQueueByUserId(playerId, ga.addProgressionEvent, ga, playerId, options) 310 | else 311 | addToInitializationQueue(ga.addProgressionEvent, ga, playerId, options) 312 | end 313 | return 314 | end 315 | 316 | if not options then 317 | return 318 | end 319 | 320 | -- Send to events 321 | local progressionStatus = options["progressionStatus"] or 0 322 | local progression01 = options["progression01"] or "" 323 | local progression02 = options["progression02"] or nil 324 | local progression03 = options["progression03"] or nil 325 | local score = options["score"] or nil 326 | local customFields = options["customFields"] 327 | 328 | events:addProgressionEvent( 329 | playerId, 330 | progressionStatus, 331 | progression01, 332 | progression02, 333 | progression03, 334 | score, 335 | customFields 336 | ) 337 | end) 338 | end 339 | 340 | function ga:addDesignEvent(playerId: number | DesignEventOptions, options: DesignEventOptions?) 341 | threading:performTaskOnGAThread(function() 342 | if not state:isEventSubmissionEnabled() then 343 | return 344 | end 345 | if 346 | not isSdkReady({ 347 | playerId = playerId, 348 | needsInitialized = true, 349 | shouldWarn = false, 350 | message = "Could not add design event", 351 | }) 352 | then 353 | if playerId then 354 | addToInitializationQueueByUserId(playerId, ga.addDesignEvent, ga, playerId, options) 355 | else 356 | addToInitializationQueue(ga.addDesignEvent, ga, playerId, options) 357 | end 358 | return 359 | end 360 | 361 | if not options then 362 | return 363 | end 364 | 365 | -- Send to events 366 | local eventId = options["eventId"] or "" 367 | local value = options["value"] or nil 368 | local customFields = options["customFields"] 369 | 370 | events:addDesignEvent(playerId, eventId, value, customFields) 371 | end) 372 | end 373 | 374 | function ga:addErrorEvent(playerId: number | ErrorEventOptions, options: ErrorEventOptions?) 375 | threading:performTaskOnGAThread(function() 376 | if not state:isEventSubmissionEnabled() then 377 | return 378 | end 379 | if 380 | not isSdkReady({ 381 | playerId = playerId, 382 | needsInitialized = true, 383 | shouldWarn = false, 384 | message = "Could not add error event", 385 | }) 386 | then 387 | if playerId then 388 | addToInitializationQueueByUserId(playerId, ga.addErrorEvent, ga, playerId, options) 389 | else 390 | addToInitializationQueue(ga.addErrorEvent, ga, playerId, options) 391 | end 392 | return 393 | end 394 | 395 | if not options then 396 | return 397 | end 398 | 399 | -- Send to events 400 | local severity = options["severity"] or 0 401 | local message = options["message"] or "" 402 | local customFields = options["customFields"] 403 | 404 | events:addErrorEvent(playerId, severity, message, customFields) 405 | end) 406 | end 407 | 408 | function ga:setEnabledDebugLog(flag: boolean) 409 | if RunService:IsStudio() then 410 | if flag then 411 | logger:setDebugLog(flag) 412 | logger:i("Debug logging enabled") 413 | else 414 | logger:i("Debug logging disabled") 415 | logger:setDebugLog(flag) 416 | end 417 | else 418 | logger:i("setEnabledDebugLog can only be used in studio") 419 | end 420 | end 421 | 422 | function ga:setEnabledInfoLog(flag: boolean) 423 | if flag then 424 | logger:setInfoLog(flag) 425 | logger:i("Info logging enabled") 426 | else 427 | logger:i("Info logging disabled") 428 | logger:setInfoLog(flag) 429 | end 430 | end 431 | 432 | function ga:setEnabledVerboseLog(flag: boolean) 433 | if flag then 434 | logger:setVerboseLog(flag) 435 | logger:ii("Verbose logging enabled") 436 | else 437 | logger:ii("Verbose logging disabled") 438 | logger:setVerboseLog(flag) 439 | end 440 | end 441 | 442 | function ga:setEnabledEventSubmission(flag: boolean) 443 | threading:performTaskOnGAThread(function() 444 | if flag then 445 | state:setEventSubmission(flag) 446 | logger:i("Event submission enabled") 447 | else 448 | logger:i("Event submission disabled") 449 | state:setEventSubmission(flag) 450 | end 451 | end) 452 | end 453 | 454 | function ga:setCustomDimension01(playerId: number | CustomDimension, dimension: CustomDimension?) 455 | threading:performTaskOnGAThread(function() 456 | if not validation:validateDimension(state._availableCustomDimensions01, dimension) then 457 | logger:w( 458 | "Could not set custom01 dimension value to '" 459 | .. (dimension or "") 460 | .. "'. Value not found in available custom01 dimension values" 461 | ) 462 | return 463 | end 464 | 465 | if 466 | not isSdkReady({ 467 | playerId = playerId, 468 | needsInitialized = true, 469 | shouldWarn = true, 470 | message = "Could not set custom01 dimension", 471 | }) 472 | then 473 | return 474 | end 475 | 476 | state:setCustomDimension01(playerId, dimension) 477 | end) 478 | end 479 | 480 | function ga:setCustomDimension02(playerId: number | CustomDimension, dimension: CustomDimension?) 481 | threading:performTaskOnGAThread(function() 482 | if not validation:validateDimension(state._availableCustomDimensions02, dimension) then 483 | logger:w( 484 | "Could not set custom02 dimension value to '" 485 | .. (dimension or "") 486 | .. "'. Value not found in available custom02 dimension values" 487 | ) 488 | return 489 | end 490 | 491 | if 492 | not isSdkReady({ 493 | playerId = playerId, 494 | needsInitialized = true, 495 | shouldWarn = true, 496 | message = "Could not set custom02 dimension", 497 | }) 498 | then 499 | return 500 | end 501 | 502 | state:setCustomDimension02(playerId, dimension) 503 | end) 504 | end 505 | 506 | function ga:setCustomDimension03(playerId: number | CustomDimension, dimension: CustomDimension?) 507 | threading:performTaskOnGAThread(function() 508 | if not validation:validateDimension(state._availableCustomDimensions03, dimension) then 509 | logger:w( 510 | "Could not set custom03 dimension value to '" 511 | .. (dimension or "") 512 | .. "'. Value not found in available custom03 dimension values" 513 | ) 514 | return 515 | end 516 | 517 | if 518 | not isSdkReady({ 519 | playerId = playerId, 520 | needsInitialized = true, 521 | shouldWarn = true, 522 | message = "Could not set custom03 dimension", 523 | }) 524 | then 525 | return 526 | end 527 | 528 | state:setCustomDimension03(playerId, dimension) 529 | end) 530 | end 531 | 532 | function ga:setEnabledReportErrors(flag: boolean) 533 | threading:performTaskOnGAThread(function() 534 | state.ReportErrors = flag 535 | end) 536 | end 537 | 538 | function ga:setEnabledCustomUserId(flag: boolean) 539 | threading:performTaskOnGAThread(function() 540 | state.UseCustomUserId = flag 541 | end) 542 | end 543 | 544 | function ga:setEnabledAutomaticSendBusinessEvents(flag: boolean) 545 | threading:performTaskOnGAThread(function() 546 | state.AutomaticSendBusinessEvents = flag 547 | end) 548 | end 549 | 550 | function ga:addGameAnalyticsTeleportData(playerIds: { number }, teleportData: TeleportData) 551 | local gameAnalyticsTeleportData = {} 552 | for _, playerId in ipairs(playerIds) do 553 | local PlayerData = store:GetPlayerDataFromCache(playerId) 554 | PlayerData.PlayerTeleporting = true 555 | local data = { 556 | ["SessionID"] = PlayerData.SessionID, 557 | ["Sessions"] = PlayerData.Sessions, 558 | ["SessionStart"] = PlayerData.SessionStart, 559 | } 560 | 561 | gameAnalyticsTeleportData[tostring(playerId)] = data 562 | end 563 | 564 | teleportData["gameanalyticsData"] = gameAnalyticsTeleportData 565 | 566 | return teleportData 567 | end 568 | 569 | function ga:getRemoteConfigsValueAsString(playerId: number | RemoteConfigs, options: RemoteConfigs) 570 | local key = options["key"] or "" 571 | local defaultValue = options["defaultValue"] or nil 572 | return state:getRemoteConfigsStringValue(playerId, key, defaultValue) 573 | end 574 | 575 | function ga:isRemoteConfigsReady(playerId: number) 576 | return state:isRemoteConfigsReady(playerId) 577 | end 578 | 579 | function ga:getRemoteConfigsContentAsString(playerId: number) 580 | return state:getRemoteConfigsContentAsString(playerId) 581 | end 582 | 583 | function ga:PlayerJoined(Player: Player) 584 | local joinData = Player:GetJoinData() 585 | local teleportData = joinData.TeleportData 586 | local gaData = nil 587 | 588 | --Variables 589 | local PlayerData = store:GetPlayerData(Player) 590 | 591 | if teleportData and typeof(teleportData) == "table" then 592 | gaData = teleportData.gameanalyticsData and teleportData.gameanalyticsData[tostring(Player.UserId)] 593 | end 594 | 595 | local pd = store:GetPlayerDataFromCache(Player.UserId) 596 | if pd then 597 | if gaData then 598 | pd.SessionID = gaData.SessionID 599 | pd.SessionStart = gaData.SessionStart 600 | end 601 | pd.PlayerTeleporting = false 602 | return 603 | end 604 | 605 | local PlayerPlatform = "unknown" 606 | local isGetPlatformSuccessful, platform = Postie.invokeClient("getPlatform", Player, 5) 607 | if isGetPlatformSuccessful then 608 | PlayerPlatform = platform 609 | end 610 | 611 | --Fill Data 612 | for key, value in pairs(store.BasePlayerData) do 613 | if PlayerData[key] then 614 | continue 615 | end 616 | 617 | if typeof(value) == "table" then 618 | PlayerData[key] = utilities:copyTable(value) 619 | else 620 | PlayerData[key] = value 621 | end 622 | end 623 | 624 | local countryCodeResult, countryCode = pcall(function() 625 | return LocalizationService:GetCountryRegionForPlayerAsync(Player) 626 | end) 627 | 628 | if countryCodeResult then 629 | PlayerData.CountryCode = countryCode 630 | end 631 | 632 | store.PlayerCache[Player.UserId] = PlayerData 633 | 634 | PlayerData.Platform = (PlayerPlatform == "Console" and "uwp_console") 635 | or (PlayerPlatform == "Mobile" and "uwp_mobile") 636 | or (PlayerPlatform == "Desktop" and "uwp_desktop") 637 | or "uwp_desktop" 638 | PlayerData.OS = PlayerData.Platform .. " 0.0.0" 639 | 640 | if not countryCodeResult then 641 | events:addSdkErrorEvent( 642 | Player.UserId, 643 | "event_validation", 644 | "player_joined", 645 | "string_empty_or_null", 646 | "country_code", 647 | "" 648 | ) 649 | end 650 | 651 | local PlayerCustomUserId = "" 652 | if state.UseCustomUserId then 653 | local isGetCustomUserIdSuccessful, customUserId = Postie.invokeClient("getCustomUserId", Player, 5) 654 | if isGetCustomUserIdSuccessful then 655 | PlayerCustomUserId = customUserId 656 | end 657 | end 658 | 659 | if not utilities:isStringNullOrEmpty(PlayerCustomUserId) then 660 | logger:i("Using custom id: " .. PlayerCustomUserId) 661 | PlayerData.CustomUserId = PlayerCustomUserId 662 | end 663 | 664 | ga:startNewSession(Player, gaData) 665 | 666 | OnPlayerReadyEvent = OnPlayerReadyEvent or ReplicatedStorage:WaitForChild("OnPlayerReadyEvent") 667 | OnPlayerReadyEvent:Fire(Player) 668 | 669 | --Validate 670 | if state.AutomaticSendBusinessEvents then 671 | --Website gamepasses 672 | if PlayerData.OwnedGamepasses == nil then --player is new (or is playing after SDK update) 673 | PlayerData.OwnedGamepasses = {} 674 | for _, id in ipairs(state._availableGamepasses) do 675 | if MKT:UserOwnsGamePassAsync(Player.UserId, id) then 676 | table.insert(PlayerData.OwnedGamepasses, id) 677 | end 678 | end 679 | --Player's data is now up to date. gamepass purchases on website can now be tracked in future visits 680 | store.PlayerCache[Player.UserId] = PlayerData 681 | store:SavePlayerData(Player) 682 | else 683 | --build a list of the game passes a user owns 684 | local currentlyOwned = {} 685 | for _, id in ipairs(state._availableGamepasses) do 686 | if MKT:UserOwnsGamePassAsync(Player.UserId, id) then 687 | table.insert(currentlyOwned, id) 688 | end 689 | end 690 | 691 | --make a table so it's easier to compare to stored game passes 692 | local storedGamepassesTable = {} 693 | for _, id in ipairs(PlayerData.OwnedGamepasses) do 694 | storedGamepassesTable[id] = true 695 | end 696 | 697 | --compare stored game passes to currently owned game passses 698 | for _, id in ipairs(currentlyOwned) do 699 | if not storedGamepassesTable[id] then 700 | table.insert(PlayerData.OwnedGamepasses, id) 701 | 702 | local gamepassInfo = ProductCache[id] 703 | 704 | --Cache 705 | if not gamepassInfo then 706 | --Get 707 | gamepassInfo = MKT:GetProductInfo(id, Enum.InfoType.GamePass) 708 | ProductCache[id] = gamepassInfo 709 | end 710 | 711 | ga:addBusinessEvent(Player.UserId, { 712 | amount = gamepassInfo.PriceInRobux, 713 | itemType = "Gamepass", 714 | itemId = ga:filterForBusinessEvent(gamepassInfo.Name), 715 | cartType = "Website", 716 | }) 717 | end 718 | end 719 | 720 | store.PlayerCache[Player.UserId] = PlayerData 721 | 722 | store:SavePlayerData(Player) 723 | end 724 | end 725 | 726 | local playerEventQueue = InitializationQueueByUserId[Player.UserId] 727 | if playerEventQueue then 728 | InitializationQueueByUserId[Player.UserId] = nil 729 | for _, queuedFunction in ipairs(playerEventQueue) do 730 | queuedFunction.Func(unpack(queuedFunction.Args)) 731 | end 732 | 733 | logger:i("Player initialization queue called #" .. #playerEventQueue .. " events") 734 | end 735 | end 736 | 737 | function ga:PlayerRemoved(Player: Player) 738 | --Save 739 | store:SavePlayerData(Player) 740 | 741 | local PlayerData = store:GetPlayerDataFromCache(Player.UserId) 742 | if PlayerData then 743 | if not PlayerData.PlayerTeleporting then 744 | ga:endSession(Player.UserId) 745 | else 746 | store.PlayerCache[Player.UserId] = nil 747 | store.DataStoreQueue.RemoveKey(Player.UserId) 748 | end 749 | end 750 | end 751 | 752 | function ga:isPlayerReady(playerId: number) 753 | if store:GetPlayerDataFromCache(playerId) then 754 | return true 755 | else 756 | return false 757 | end 758 | end 759 | 760 | function ga:ProcessReceiptCallback(Info: ProcessReceiptInfo) 761 | --Variables 762 | local ProductInfo = ProductCache[Info.ProductId] :: ProductInfo? 763 | 764 | --Cache 765 | if not ProductInfo then 766 | --Get 767 | pcall(function() 768 | ProductInfo = MKT:GetProductInfo(Info.ProductId, Enum.InfoType.Product) 769 | ProductCache[Info.ProductId] = ProductInfo 770 | end) 771 | end 772 | 773 | if ProductInfo then 774 | ga:addBusinessEvent(Info.PlayerId, { 775 | amount = Info.CurrencySpent, 776 | itemType = "DeveloperProduct", 777 | itemId = ga:filterForBusinessEvent(ProductInfo.Name), 778 | }) 779 | end 780 | end 781 | 782 | --customGamepassInfo argument to optinaly provide our own name or price 783 | function ga:GamepassPurchased(player: Player, id: number, customGamepassInfo: ProductInfo?) 784 | local gamepassInfo = ProductCache[id] 785 | 786 | --Cache 787 | if not gamepassInfo then 788 | --Get 789 | gamepassInfo = MKT:GetProductInfo(id, Enum.InfoType.GamePass) 790 | ProductCache[id] = gamepassInfo 791 | end 792 | 793 | local amount = 0 794 | local itemId = "GamePass" 795 | if customGamepassInfo then 796 | amount = customGamepassInfo.PriceInRobux 797 | itemId = customGamepassInfo.Name 798 | elseif gamepassInfo then 799 | amount = gamepassInfo.PriceInRobux 800 | itemId = gamepassInfo.Name 801 | end 802 | 803 | ga:addBusinessEvent(player.UserId, { 804 | amount = amount or 0, 805 | itemType = "Gamepass", 806 | itemId = ga:filterForBusinessEvent(itemId), 807 | gamepassId = id, 808 | }) 809 | end 810 | 811 | local requiredInitializationOptions = { "gameKey", "secretKey" } 812 | 813 | function ga:initServer(gameKey: string, secretKey: string) 814 | ga:initialize({ 815 | gameKey = gameKey, 816 | secretKey = secretKey, 817 | }) 818 | end 819 | 820 | function ga:initialize(options: GameAnalyticsOptions) 821 | threading:performTaskOnGAThread(function() 822 | for _, option in ipairs(requiredInitializationOptions) do 823 | if options[option] == nil then 824 | logger:e("Initialize '" .. option .. "' option missing") 825 | return 826 | end 827 | end 828 | if options.enableInfoLog ~= nil and options.enableInfoLog then 829 | ga:setEnabledInfoLog(options.enableInfoLog) 830 | end 831 | if options.enableVerboseLog ~= nil and options.enableVerboseLog then 832 | ga:setEnabledVerboseLog(options.enableVerboseLog) 833 | end 834 | if options.availableCustomDimensions01 ~= nil and #options.availableCustomDimensions01 > 0 then 835 | ga:configureAvailableCustomDimensions01(options.availableCustomDimensions01) 836 | end 837 | if options.availableCustomDimensions02 ~= nil and #options.availableCustomDimensions02 > 0 then 838 | ga:configureAvailableCustomDimensions02(options.availableCustomDimensions02) 839 | end 840 | if options.availableCustomDimensions03 ~= nil and #options.availableCustomDimensions03 > 0 then 841 | ga:configureAvailableCustomDimensions03(options.availableCustomDimensions03) 842 | end 843 | if options.availableResourceCurrencies ~= nil and #options.availableResourceCurrencies > 0 then 844 | ga:configureAvailableResourceCurrencies(options.availableResourceCurrencies) 845 | end 846 | if options.availableResourceItemTypes ~= nil and #options.availableResourceItemTypes > 0 then 847 | ga:configureAvailableResourceItemTypes(options.availableResourceItemTypes) 848 | end 849 | if options.build ~= nil and #options.build > 0 then 850 | ga:configureBuild(options.build) 851 | end 852 | if options.availableGamepasses ~= nil and #options.availableGamepasses > 0 then 853 | ga:configureAvailableGamepasses(options.availableGamepasses) 854 | end 855 | if options.enableDebugLog ~= nil then 856 | ga:setEnabledDebugLog(options.enableDebugLog) 857 | end 858 | 859 | if options.automaticSendBusinessEvents ~= nil then 860 | ga:setEnabledAutomaticSendBusinessEvents(options.automaticSendBusinessEvents) 861 | end 862 | if options.reportErrors ~= nil then 863 | ga:setEnabledReportErrors(options.reportErrors) 864 | end 865 | 866 | if options.useCustomUserId ~= nil then 867 | ga:setEnabledCustomUserId(options.useCustomUserId) 868 | end 869 | 870 | if isSdkReady({ needsInitialized = true, shouldWarn = false }) then 871 | logger:w("SDK already initialized. Can only be called once.") 872 | return 873 | end 874 | 875 | local gameKey = options["gameKey"] 876 | local secretKey = options["secretKey"] 877 | 878 | if not validation:validateKeys(gameKey, secretKey) then 879 | logger:w( 880 | "SDK failed initialize. Game key or secret key is invalid. Can only contain characters A-z 0-9, gameKey is 32 length, secretKey is 40 length. Failed keys - gameKey: " 881 | .. gameKey 882 | .. ", secretKey: " 883 | .. secretKey 884 | ) 885 | return 886 | end 887 | 888 | events.GameKey = gameKey 889 | events.SecretKey = secretKey 890 | 891 | state.Initialized = true 892 | 893 | -- New Players 894 | Players.PlayerAdded:Connect(function(Player) 895 | ga:PlayerJoined(Player) 896 | end) 897 | 898 | -- Players leaving 899 | Players.PlayerRemoving:Connect(function(Player) 900 | ga:PlayerRemoved(Player) 901 | end) 902 | 903 | -- Fire for players already in game 904 | for _, Player in ipairs(Players:GetPlayers()) do 905 | coroutine.wrap(ga.PlayerJoined)(ga, Player) 906 | end 907 | 908 | for _, queuedFunction in ipairs(InitializationQueue) do 909 | task.spawn(queuedFunction.Func, unpack(queuedFunction.Args)) 910 | end 911 | logger:i("Server initialization queue called #" .. #InitializationQueue .. " events") 912 | InitializationQueue = {} 913 | 914 | events:processEventQueue() 915 | end) 916 | end 917 | 918 | if not ReplicatedStorage:FindFirstChild("GameAnalyticsRemoteConfigs") then 919 | --Create 920 | local f = Instance.new("RemoteEvent") 921 | f.Name = "GameAnalyticsRemoteConfigs" 922 | f.Parent = ReplicatedStorage 923 | end 924 | 925 | if not ReplicatedStorage:FindFirstChild("OnPlayerReadyEvent") then 926 | --Create 927 | local f = Instance.new("BindableEvent") 928 | f.Name = "OnPlayerReadyEvent" 929 | f.Parent = ReplicatedStorage 930 | end 931 | 932 | task.spawn(function() 933 | local currentHour = math.floor(os.time() / 3600) 934 | ErrorDS = store:GetErrorDataStore(currentHour) 935 | 936 | while task.wait(ONE_HOUR_IN_SECONDS) do 937 | currentHour = math.floor(os.time() / 3600) 938 | ErrorDS = store:GetErrorDataStore(currentHour) 939 | errorCountCache = {} 940 | errorCountCacheKeys = {} 941 | end 942 | end) 943 | 944 | task.spawn(function() 945 | while task.wait(store.AutoSaveData) do 946 | for _, key in pairs(errorCountCacheKeys) do 947 | local errorCount = errorCountCache[key] 948 | local step = errorCount.currentCount - errorCount.countInDS 949 | errorCountCache[key].countInDS = store:IncrementErrorCount(ErrorDS, key, step) 950 | errorCountCache[key].currentCount = errorCountCache[key].countInDS 951 | end 952 | end 953 | end) 954 | 955 | local function ErrorHandler(message, trace, scriptName, player) 956 | local scriptNameTmp = "(null)" 957 | if scriptName ~= nil then 958 | scriptNameTmp = scriptName 959 | end 960 | local messageTmp = "(null)" 961 | if message ~= nil then 962 | messageTmp = message 963 | end 964 | local traceTmp = "(null)" 965 | if trace ~= nil then 966 | traceTmp = trace 967 | end 968 | local m = scriptNameTmp .. ": message=" .. messageTmp .. ", trace=" .. traceTmp 969 | if #m > 8192 then 970 | m = string.sub(m, 1, 8192) 971 | end 972 | 973 | local userId = nil 974 | if player then 975 | userId = player.UserId 976 | m = m:gsub(player.Name, "[LocalPlayer]") -- so we don't flood the same errors with different player names 977 | end 978 | 979 | local key = m 980 | if #key > 50 then 981 | key = string.sub(key, 1, 50) 982 | end 983 | 984 | if errorCountCache[key] == nil then 985 | errorCountCacheKeys[#errorCountCacheKeys + 1] = key 986 | errorCountCache[key] = {} 987 | errorCountCache[key].countInDS = 0 988 | errorCountCache[key].currentCount = 0 989 | end 990 | 991 | -- don't report error if limit has been exceeded 992 | if errorCountCache[key].currentCount > MaxErrorsPerHour then 993 | return 994 | end 995 | 996 | ga:addErrorEvent(userId, { 997 | severity = ga.EGAErrorSeverity.error, 998 | message = m, 999 | }) 1000 | 1001 | -- increment error count 1002 | errorCountCache[key].currentCount = errorCountCache[key].currentCount + 1 1003 | end 1004 | 1005 | local function ErrorHandlerFromServer(message, trace, Script) 1006 | --Validate 1007 | if not state.ReportErrors then 1008 | return 1009 | end 1010 | 1011 | if not Script then -- don't remember if this check is necessary but must have added it for a reason 1012 | return 1013 | end 1014 | 1015 | local scriptName = nil 1016 | local ok, _ = pcall(function() 1017 | scriptName = Script:GetFullName() -- CoreGui.RobloxGui.Modules.PlayerList error, can't get name because of security permission 1018 | end) 1019 | if not ok then 1020 | return 1021 | end 1022 | 1023 | return ErrorHandler(message, trace, scriptName) 1024 | end 1025 | 1026 | local function ErrorHandlerFromClient(message, trace, scriptName, player) 1027 | --Validate 1028 | if not state.ReportErrors then 1029 | return 1030 | end 1031 | 1032 | return ErrorHandler(message, trace, scriptName, player) 1033 | end 1034 | 1035 | --Error Logging 1036 | ScriptContext.Error:Connect(ErrorHandlerFromServer) 1037 | if not ReplicatedStorage:FindFirstChild("GameAnalyticsError") then 1038 | --Create 1039 | local f = Instance.new("RemoteEvent") 1040 | f.Name = "GameAnalyticsError" 1041 | f.Parent = ReplicatedStorage 1042 | end 1043 | 1044 | ReplicatedStorage.GameAnalyticsError.OnServerEvent:Connect(function(player, message, trace, scriptName) 1045 | ErrorHandlerFromClient(message, trace, scriptName, player) 1046 | end) 1047 | 1048 | --Record Gamepasses. 1049 | MKT.PromptGamePassPurchaseFinished:Connect(function(Player, ID, Purchased) 1050 | --Validate 1051 | if not state.AutomaticSendBusinessEvents or not Purchased then 1052 | return 1053 | end 1054 | 1055 | ga:GamepassPurchased(Player, ID) 1056 | end) 1057 | 1058 | return ga 1059 | -------------------------------------------------------------------------------- /gameanalytics-sdk/GameAnalyticsClient.lua: -------------------------------------------------------------------------------- 1 | local module = {} 2 | 3 | local GuiService = game:GetService("GuiService") 4 | local UserInputService = game:GetService("UserInputService") 5 | local ReplicatedStorage = game:GetService("ReplicatedStorage") 6 | local ScriptContext = game:GetService("ScriptContext") 7 | 8 | --[[ 9 | The modules are required inside each function because we wouldn't 10 | want to load the GameAnalytics library if we're just requiring this 11 | ModuleScript on the client side, and we don't need to hard code 12 | a require to Postie on the server side because the networking implementation could change. 13 | ]] 14 | 15 | function module.initClient() 16 | local Postie = require(script.Parent.GameAnalytics.Postie) 17 | 18 | ScriptContext.Error:Connect(function(message, stackTrace, scriptInst) 19 | if not scriptInst then 20 | return 21 | end 22 | 23 | local scriptName = nil 24 | local ok, _ = pcall(function() 25 | scriptName = scriptInst:GetFullName() -- Can't get name of some scripts because of security permission 26 | end) 27 | if not ok then 28 | return 29 | end 30 | 31 | ReplicatedStorage.GameAnalyticsError:FireServer(message, stackTrace, scriptName) 32 | end) 33 | 34 | --Functions 35 | local function getPlatform() 36 | if GuiService:IsTenFootInterface() then 37 | return "Console" 38 | elseif UserInputService.TouchEnabled and not UserInputService.MouseEnabled then 39 | return "Mobile" 40 | else 41 | return "Desktop" 42 | end 43 | end 44 | 45 | --Filtering 46 | Postie.setCallback("getPlatform", getPlatform) 47 | end 48 | 49 | return module 50 | -------------------------------------------------------------------------------- /gameanalytics-sdk/init.lua: -------------------------------------------------------------------------------- 1 | local RunService = game:GetService("RunService") 2 | 3 | --[[ 4 | This script determines if we should load gameanalytics server or client. 5 | ]] 6 | 7 | local isServer = RunService:IsServer() 8 | 9 | if isServer then 10 | return require(script.GameAnalytics) 11 | else 12 | return require(script.GameAnalyticsClient) 13 | end 14 | -------------------------------------------------------------------------------- /selene.toml: -------------------------------------------------------------------------------- 1 | std = "roblox" 2 | -------------------------------------------------------------------------------- /sourcemap.json: -------------------------------------------------------------------------------- 1 | {"name":"gameanalytics-sdk","className":"ModuleScript","filePaths":["gameanalytics-sdk\\init.lua","default.project.json"],"children":[{"name":"GameAnalytics","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\init.lua"],"children":[{"name":"Events","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Events.lua"]},{"name":"GAErrorSeverity","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\GAErrorSeverity.lua"]},{"name":"GAProgressionStatus","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\GAProgressionStatus.lua"]},{"name":"GAResourceFlowType","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\GAResourceFlowType.lua"]},{"name":"HttpApi","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\HttpApi\\init.lua"],"children":[{"name":"HashLib","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\HttpApi\\HashLib\\init.lua"],"children":[{"name":"Base64","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\HttpApi\\HashLib\\Base64.lua"]}]}]},{"name":"Logger","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Logger.lua"]},{"name":"Postie","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Postie.lua"]},{"name":"State","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\State.lua"]},{"name":"Store","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Store\\init.lua"],"children":[{"name":"DataStoreQueue","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Store\\DataStoreQueue.lua"]}]},{"name":"Threading","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Threading.lua"]},{"name":"Types","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Types.lua"]},{"name":"Utilities","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Utilities.lua"]},{"name":"Validation","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Validation.lua"]},{"name":"Version","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalytics\\Version.lua"]}]},{"name":"GameAnalyticsClient","className":"ModuleScript","filePaths":["gameanalytics-sdk\\GameAnalyticsClient.lua"]}]} -------------------------------------------------------------------------------- /wally.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "gameanalytics/gameanalytics-sdk" 3 | description = "Official Roblox SDK for GameAnalytics. GameAnalytics is a free analytics platform that helps game developers understand their players' behaviour by delivering relevant insights." 4 | version = "2.2.6" 5 | license = "MIT" 6 | authors = ["GameAnalytics "] 7 | realm = "shared" 8 | registry = "https://github.com/UpliftGames/wally-index" 9 | exclude = ["ExampleProject"] 10 | 11 | [dependencies] 12 | --------------------------------------------------------------------------------