diff --git a/README.markdown b/README.markdown
--- a/README.markdown
+++ b/README.markdown
@@ -39,7 +39,7 @@
 
 [Octane]: https://github.com/tfausak/octane
 [Version badge]: https://www.stackage.org/package/octane/badge/nightly?label=version
-[version]: https://www.stackage.org/package/octane
+[version]: https://www.stackage.org/nightly/package/octane
 [Windows build badge]: https://ci.appveyor.com/api/projects/status/github/tfausak/octane?branch=main&svg=true
 [windows build]: https://ci.appveyor.com/project/TaylorFausak/octane
 [Build badge]: https://travis-ci.org/tfausak/octane.svg?branch=main
diff --git a/data/classes-with-location.json b/data/classes-with-location.json
new file mode 100644
--- /dev/null
+++ b/data/classes-with-location.json
@@ -0,0 +1,18 @@
+[
+  "TAGame.Ball_TA",
+  "TAGame.CameraSettingsActor_TA",
+  "TAGame.Car_Season_TA",
+  "TAGame.Car_TA",
+  "TAGame.CarComponent_Boost_TA",
+  "TAGame.CarComponent_Dodge_TA",
+  "TAGame.CarComponent_DoubleJump_TA",
+  "TAGame.CarComponent_FlipCar_TA",
+  "TAGame.CarComponent_Jump_TA",
+  "TAGame.GameEvent_Season_TA",
+  "TAGame.GameEvent_Soccar_TA",
+  "TAGame.GameEvent_SoccarPrivate_TA",
+  "TAGame.GameEvent_SoccarSplitscreen_TA",
+  "TAGame.GRI_TA",
+  "TAGame.PRI_TA",
+  "TAGame.Team_Soccar_TA"
+]
diff --git a/data/classes-with-rotation.json b/data/classes-with-rotation.json
new file mode 100644
--- /dev/null
+++ b/data/classes-with-rotation.json
@@ -0,0 +1,5 @@
+[
+  "TAGame.Ball_TA",
+  "TAGame.Car_Season_TA",
+  "TAGame.Car_TA"
+]
diff --git a/data/classes.json b/data/classes.json
new file mode 100644
--- /dev/null
+++ b/data/classes.json
@@ -0,0 +1,36 @@
+{
+  "Archetypes.Ball.Ball_Basketball": "TAGame.Ball_TA",
+  "Archetypes.Ball.Ball_Default": "TAGame.Ball_TA",
+  "Archetypes.Ball.Ball_Puck": "TAGame.Ball_TA",
+  "Archetypes.Ball.CubeBall": "TAGame.Ball_TA",
+  "Archetypes.Car.Car_Default": "TAGame.Car_TA",
+  "Archetypes.CarComponents.CarComponent_Boost": "TAGame.CarComponent_Boost_TA",
+  "Archetypes.CarComponents.CarComponent_Dodge": "TAGame.CarComponent_Dodge_TA",
+  "Archetypes.CarComponents.CarComponent_DoubleJump": "TAGame.CarComponent_DoubleJump_TA",
+  "Archetypes.CarComponents.CarComponent_FlipCar": "TAGame.CarComponent_FlipCar_TA",
+  "Archetypes.CarComponents.CarComponent_Jump": "TAGame.CarComponent_Jump_TA",
+  "Archetypes.GameEvent.GameEvent_Basketball": "TAGame.GameEvent_Soccar_TA",
+  "Archetypes.GameEvent.GameEvent_BasketballPrivate": "TAGame.GameEvent_SoccarPrivate_TA",
+  "Archetypes.GameEvent.GameEvent_BasketballSplitscreen": "TAGame.GameEvent_SoccarSplitscreen_TA",
+  "Archetypes.GameEvent.GameEvent_HockeyPrivate": "TAGame.GameEvent_SoccarPrivate_TA",
+  "Archetypes.GameEvent.GameEvent_HockeySplitscreen": "TAGame.GameEvent_SoccarSplitscreen_TA",
+  "Archetypes.GameEvent.GameEvent_Season:CarArchetype": "TAGame.Car_TA",
+  "Archetypes.GameEvent.GameEvent_Season": "TAGame.GameEvent_Season_TA",
+  "Archetypes.GameEvent.GameEvent_Soccar": "TAGame.GameEvent_Soccar_TA",
+  "Archetypes.GameEvent.GameEvent_SoccarPrivate": "TAGame.GameEvent_SoccarPrivate_TA",
+  "Archetypes.GameEvent.GameEvent_SoccarSplitscreen": "TAGame.GameEvent_SoccarSplitscreen_TA",
+  "Archetypes.Teams.Team0": "TAGame.Team_Soccar_TA",
+  "Archetypes.Teams.Team1": "TAGame.Team_Soccar_TA",
+  "GameInfo_Basketball.GameInfo.GameInfo_Basketball:GameReplicationInfoArchetype": "TAGame.GRI_TA",
+  "Gameinfo_Hockey.GameInfo.Gameinfo_Hockey:GameReplicationInfoArchetype": "TAGame.GRI_TA",
+  "GameInfo_Season.GameInfo.GameInfo_Season:GameReplicationInfoArchetype": "TAGame.GRI_TA",
+  "GameInfo_Soccar.GameInfo.GameInfo_Soccar:GameReplicationInfoArchetype": "TAGame.GRI_TA",
+  "TAGame.CameraSettingsActor_TA:PRI": "TAGame.CameraSettingsActor_TA",
+  "TAGame.Default__CameraSettingsActor_TA": "TAGame.CameraSettingsActor_TA",
+  "TAGame.Default__PRI_TA": "TAGame.PRI_TA",
+  "TAGame.Default__VoteActor_TA": "TAGame.VoteActor_TA",
+  "TheWorld:PersistentLevel.CrowdActor_TA": "TAGame.CrowdActor_TA",
+  "TheWorld:PersistentLevel.CrowdManager_TA": "TAGame.CrowdManager_TA",
+  "TheWorld:PersistentLevel.InMapScoreboard_TA": "TAGame.InMapScoreboard_TA",
+  "TheWorld:PersistentLevel.VehiclePickup_Boost_TA": "TAGame.VehiclePickup_Boost_TA"
+}
diff --git a/data/game-modes.json b/data/game-modes.json
new file mode 100644
--- /dev/null
+++ b/data/game-modes.json
@@ -0,0 +1,5 @@
+{
+  "Soccar": 0,
+  "Hockey": 1,
+  "Hoops": 2
+}
diff --git a/data/products.json b/data/products.json
new file mode 100644
--- /dev/null
+++ b/data/products.json
@@ -0,0 +1,845 @@
+{
+  "Antenna_8Ball": 1,
+  "Antenna_Alien": 753,
+  "Antenna_Blacklight": 385,
+  "Antenna_Bomb": 614,
+  "Antenna_Bread": 391,
+  "Antenna_Candle": 757,
+  "Antenna_CandyCane": 649,
+  "Antenna_ChickMagnet": 392,
+  "Antenna_ChristmasGift": 650,
+  "Antenna_Cupcake": 393,
+  "Antenna_DiscoBall": 800,
+  "Antenna_Disconnected": 394,
+  "Antenna_DLZ": 1351,
+  "Antenna_DogBalloon": 752,
+  "Antenna_DollarSign": 3,
+  "Antenna_Donut": 395,
+  "Antenna_DOTDSkull": 574,
+  "Antenna_ETS2": 813,
+  "Antenna_Eyeball": 396,
+  "Antenna_Fallout_VaultBoy": 673,
+  "Antenna_FlakShell": 397,
+  "Antenna_Flower_Rose": 785,
+  "Antenna_Flower_Sunflower": 786,
+  "Antenna_Flower_Venus": 788,
+  "Antenna_FoamFinger": 398,
+  "Antenna_FurballFrakenstein": 593,
+  "Antenna_FurballVampire": 595,
+  "Antenna_GenieLamp": 798,
+  "Antenna_Gingerbread_Man": 5,
+  "Antenna_GoatSimulator_G2Robot": 927,
+  "Antenna_GoatSimulator_Goat": 867,
+  "Antenna_GoldNugget": 6,
+  "Antenna_Heart": 7,
+  "Antenna_Horseshoe": 8,
+  "Antenna_HulaGirl": 762,
+  "Antenna_LightningBolt": 9,
+  "Antenna_Mau5": 781,
+  "Antenna_Moai": 522,
+  "Antenna_Parrot": 750,
+  "Antenna_PeaceSign": 11,
+  "Antenna_Pinata": 400,
+  "Antenna_RadiationSymbol": 615,
+  "Antenna_RedditSnoo": 629,
+  "Antenna_Rocket": 755,
+  "Antenna_RubberDuckie": 779,
+  "Antenna_Saturn": 12,
+  "Antenna_Skull": 13,
+  "Antenna_SmileyFace": 14,
+  "Antenna_Snowman": 15,
+  "Antenna_SoccerBall": 16,
+  "Antenna_SpartanSam": 1169,
+  "Antenna_Star": 17,
+  "Antenna_SweetTooth": 18,
+  "Antenna_TennisBall": 19,
+  "Antenna_UFO": 20,
+  "Antenna_UrbanBall": 575,
+  "Antenna_UtopiaBall": 576,
+  "Antenna_VGA_Statue": 655,
+  "Antenna_Waffle": 401,
+  "Antenna_WastelandBall": 577,
+  "Antenna_Witcher3_Medallion": 749,
+  "Antenna_Worms_Grenade": 1000,
+  "AT_GladOS_Potato": 698,
+  "AT_Oddworld_Abe": 678,
+  "AT_Oddworld_Glukkon": 679,
+  "AT_Portal_PC": 686,
+  "AT_PortalCube": 681,
+  "AT_Warframe_Chroma": 682,
+  "AT_Warframe_Excalibur": 684,
+  "AT_Warframe_Loki": 683,
+  "Body_Aftershock": 1286,
+  "Body_Backfire": 21,
+  "Body_Darkcar": 803,
+  "Body_Force": 22,
+  "Body_GearsCar": 625,
+  "Body_GreyCar": 597,
+  "Body_Import": 402,
+  "Body_Interceptor": 600,
+  "Body_Marauder": 1172,
+  "Body_MuscleCar": 403,
+  "Body_NeoBike": 1317,
+  "Body_NeoCar": 1171,
+  "Body_Octane": 23,
+  "Body_Orion": 24,
+  "Body_Rhino": 25,
+  "Body_Scarab": 404,
+  "Body_Spark": 26,
+  "Body_SweetTooth": 27,
+  "Body_Torch": 28,
+  "Body_Torment": 29,
+  "Body_Vanquish": 30,
+  "Body_Venom": 31,
+  "Body_Warthog": 723,
+  "Body_WastelandTruck": 607,
+  "Body_Zippy": 523,
+  "Boost_AlphaReward": 32,
+  "Boost_Bubble": 33,
+  "Boost_Burnout": 387,
+  "Boost_CandyCorn": 578,
+  "Boost_Challenger": 1334,
+  "Boost_Champion": 1335,
+  "Boost_Confetti": 34,
+  "Boost_ConversionGel": 651,
+  "Boost_DarkCar": 868,
+  "Boost_Digital": 35,
+  "Boost_Flamethrower_Blue": 37,
+  "Boost_Flamethrower_Green": 38,
+  "Boost_Flamethrower_Pink": 39,
+  "Boost_Flamethrower_Purple": 40,
+  "Boost_Flamethrower_Red": 41,
+  "Boost_Flamethrower_Yellow": 1318,
+  "Boost_Flamethrower": 36,
+  "Boost_Flowers": 42,
+  "Boost_GoW_Imulsion": 729,
+  "Boost_Grass": 43,
+  "Boost_GreyCar": 626,
+  "Boost_Hearts": 1091,
+  "Boost_HolyLight": 44,
+  "boost_iceshards": 1166,
+  "Boost_Lightning": 405,
+  "Boost_LightTrail_Green": 46,
+  "Boost_LightTrail_Pink": 47,
+  "Boost_LightTrail_Purple": 48,
+  "Boost_LightTrail_Red": 49,
+  "Boost_LightTrail_Yellow": 50,
+  "Boost_LightTrail": 45,
+  "Boost_Money": 51,
+  "Boost_MusicalNotes": 524,
+  "Boost_Needler": 715,
+  "Boost_Nitrous": 384,
+  "Boost_NutsBolts": 544,
+  "Boost_Overcharge": 714,
+  "Boost_Plasma": 52,
+  "Boost_Presents": 635,
+  "Boost_Propulsion_Blue": 54,
+  "Boost_Propulsion_Green": 55,
+  "Boost_Propulsion_Pink": 56,
+  "Boost_Propulsion_Purple": 57,
+  "Boost_Propulsion_Yellow": 58,
+  "Boost_Propulsion": 53,
+  "Boost_PropulsionGel": 652,
+  "Boost_Prospect": 1336,
+  "Boost_Rainbow": 59,
+  "Boost_RepulsionGel": 653,
+  "Boost_SandStorm": 664,
+  "Boost_Slime": 60,
+  "Boost_Snow": 61,
+  "Boost_Sparkles": 62,
+  "Boost_Standard_Blue": 64,
+  "Boost_Standard_Green": 1319,
+  "Boost_Standard_Pink": 65,
+  "Boost_Standard_Purple": 66,
+  "Boost_Standard_Yellow": 67,
+  "Boost_Standard": 63,
+  "Boost_Star": 1337,
+  "Boost_Stars": 545,
+  "Boost_SweetTooth": 68,
+  "Boost_Toon": 1094,
+  "Boost_UVGun": 718,
+  "Boost_Water": 69,
+  "Bot_Astro": 70,
+  "Bot_Bane": 71,
+  "Bot_Beast": 72,
+  "Bot_Blade": 73,
+  "Bot_C-Block": 74,
+  "Bot_Captain": 75,
+  "Bot_Caveman": 76,
+  "Bot_Cougar": 77,
+  "Bot_Devil": 78,
+  "Bot_Diesel": 79,
+  "Bot_Dotty": 80,
+  "Bot_Dude": 81,
+  "Bot_Duke": 82,
+  "Bot_Dynamite": 83,
+  "Bot_Flame": 84,
+  "Bot_Flash": 85,
+  "Bot_Fossil": 86,
+  "Bot_Fury": 87,
+  "Bot_Hawk": 88,
+  "Bot_Hunter": 89,
+  "Bot_Imp": 90,
+  "Bot_Jet": 91,
+  "Bot_JM": 92,
+  "Bot_Kidd": 93,
+  "Bot_Laser": 94,
+  "Bot_Lightning": 95,
+  "Bot_Lucky": 96,
+  "Bot_Middy": 97,
+  "Bot_Mountain": 98,
+  "Bot_Nitro": 99,
+  "Bot_Outlaw": 100,
+  "Bot_Phoenix": 101,
+  "Bot_Raja": 102,
+  "Bot_Razor": 103,
+  "Bot_Retro": 104,
+  "Bot_Roundhouse": 105,
+  "Bot_Sabre": 106,
+  "Bot_Samson": 107,
+  "Bot_Savage": 108,
+  "Bot_Shadow": 109,
+  "Bot_Shield": 110,
+  "Bot_Siren": 111,
+  "Bot_Sky": 112,
+  "Bot_Squall": 113,
+  "Bot_Tank": 114,
+  "Bot_Thunder": 115,
+  "Bot_Titan": 116,
+  "Bot_Turbo": 117,
+  "Bot_Tusk": 118,
+  "Bot_Viper": 119,
+  "Bot_Warrior": 120,
+  "Bot_Wolf": 121,
+  "Bot_Yuri": 122,
+  "Bot_Zap": 123,
+  "CountryFlag_Afganistan": 407,
+  "CountryFlag_Albania": 408,
+  "CountryFlag_Algeria": 124,
+  "CountryFlag_American_Samoa": 409,
+  "CountryFlag_Andorra": 830,
+  "CountryFlag_Angola": 410,
+  "CountryFlag_Anguilla": 831,
+  "CountryFlag_Antigua": 832,
+  "CountryFlag_Argentina": 125,
+  "CountryFlag_Armenia": 411,
+  "CountryFlag_Aruba": 833,
+  "CountryFlag_Australia": 126,
+  "CountryFlag_Austria": 127,
+  "CountryFlag_Azerbaijan": 412,
+  "CountryFlag_Bahamas": 834,
+  "CountryFlag_Bahrain": 550,
+  "CountryFlag_Bangladesh": 413,
+  "CountryFlag_Barbados": 835,
+  "CountryFlag_Belarus": 414,
+  "CountryFlag_Belgium": 128,
+  "CountryFlag_Belize": 551,
+  "CountryFlag_Benin": 415,
+  "CountryFlag_Bermuda": 836,
+  "CountryFlag_Bhutan": 552,
+  "CountryFlag_Bolivia": 416,
+  "CountryFlag_Bosnia": 129,
+  "CountryFlag_Botswana": 553,
+  "CountryFlag_Brazil": 130,
+  "CountryFlag_Bulgaria": 131,
+  "CountryFlag_Burkina_Faso": 417,
+  "CountryFlag_Burma": 418,
+  "CountryFlag_Burundi": 419,
+  "CountryFlag_BVE": 837,
+  "CountryFlag_Cambodia": 420,
+  "CountryFlag_Cameroon": 132,
+  "CountryFlag_Canada": 133,
+  "CountryFlag_Cape_Verde": 134,
+  "CountryFlag_CAR": 520,
+  "CountryFlag_CaymanIslands": 838,
+  "CountryFlag_Chad": 422,
+  "CountryFlag_Chile": 135,
+  "CountryFlag_China": 136,
+  "CountryFlag_Columbia": 137,
+  "CountryFlag_Comoros": 554,
+  "CountryFlag_Congo_DR": 423,
+  "CountryFlag_Congo": 138,
+  "CountryFlag_CookIslands": 839,
+  "CountryFlag_CostaRica": 139,
+  "CountryFlag_Croatia": 140,
+  "CountryFlag_Cuba": 424,
+  "CountryFlag_Curacao": 840,
+  "CountryFlag_Cyprus": 141,
+  "CountryFlag_CzechRepublic": 142,
+  "CountryFlag_Denmark": 143,
+  "CountryFlag_Djibouti": 555,
+  "CountryFlag_Dominica": 841,
+  "CountryFlag_Dominican_Republic": 425,
+  "CountryFlag_EastTimor": 556,
+  "CountryFlag_Ecuador": 144,
+  "CountryFlag_Egypt": 426,
+  "CountryFlag_El_Salvador": 145,
+  "CountryFlag_England": 146,
+  "CountryFlag_Equatorial_Guinea": 147,
+  "CountryFlag_Eritrea": 427,
+  "CountryFlag_Estonia": 428,
+  "CountryFlag_Ethiopia": 429,
+  "CountryFlag_FalklandIslands": 842,
+  "CountryFlag_FaroeIslands": 843,
+  "CountryFlag_Fiji": 557,
+  "CountryFlag_Finland": 148,
+  "CountryFlag_France": 149,
+  "CountryFlag_Gabon": 430,
+  "CountryFlag_Gambia": 558,
+  "CountryFlag_Georgia": 431,
+  "CountryFlag_Germany": 150,
+  "CountryFlag_Ghana": 151,
+  "CountryFlag_Gibraltar": 844,
+  "CountryFlag_Greece": 152,
+  "CountryFlag_Grenada": 863,
+  "CountryFlag_Guam": 432,
+  "CountryFlag_Guatemala": 153,
+  "CountryFlag_Guinea": 154,
+  "CountryFlag_GuineaBissau": 559,
+  "CountryFlag_Guyana": 560,
+  "CountryFlag_Haiti": 433,
+  "CountryFlag_Honduras": 155,
+  "CountryFlag_Hong_Kong": 156,
+  "CountryFlag_Hungary": 157,
+  "CountryFlag_Iceland": 158,
+  "CountryFlag_India": 159,
+  "CountryFlag_Indonesia": 160,
+  "CountryFlag_Iran": 161,
+  "CountryFlag_Iraq": 434,
+  "CountryFlag_Ireland": 162,
+  "CountryFlag_IsleOfMan": 845,
+  "CountryFlag_Israel": 163,
+  "CountryFlag_Italy": 164,
+  "CountryFlag_IvoryCoast": 165,
+  "CountryFlag_Jamaica": 166,
+  "CountryFlag_Japan": 167,
+  "CountryFlag_Jordan": 435,
+  "CountryFlag_Kazakhstan": 436,
+  "CountryFlag_Kenya": 168,
+  "CountryFlag_Kiribati": 846,
+  "CountryFlag_Kosovo": 561,
+  "CountryFlag_Kuwait": 437,
+  "CountryFlag_Kyrgystan": 438,
+  "CountryFlag_Laos": 439,
+  "CountryFlag_Latvia": 440,
+  "CountryFlag_Lebanon": 441,
+  "CountryFlag_Lesotho": 562,
+  "CountryFlag_Liberia": 442,
+  "CountryFlag_Libya": 443,
+  "CountryFlag_Lithuania": 444,
+  "CountryFlag_Luxembourg": 169,
+  "CountryFlag_Macau": 563,
+  "CountryFlag_Macedonia": 445,
+  "CountryFlag_Madagascar": 446,
+  "CountryFlag_Malawi": 447,
+  "CountryFlag_Malaysia": 170,
+  "CountryFlag_Maldives": 847,
+  "CountryFlag_Mali": 448,
+  "CountryFlag_Malta": 171,
+  "CountryFlag_MarshallIslands": 848,
+  "CountryFlag_Mauritania": 449,
+  "CountryFlag_Mauritius": 564,
+  "CountryFlag_Mexico": 172,
+  "CountryFlag_Micronesia": 849,
+  "CountryFlag_Moldova": 450,
+  "CountryFlag_Mongolia": 451,
+  "CountryFlag_Montenegro": 452,
+  "CountryFlag_Montserrat": 864,
+  "CountryFlag_Morocco": 453,
+  "CountryFlag_Mozambique": 454,
+  "CountryFlag_N_Mariana_Islands": 456,
+  "CountryFlag_Namibia": 457,
+  "CountryFlag_Nepal": 458,
+  "CountryFlag_Netherlands": 173,
+  "CountryFlag_New_Zealand": 174,
+  "CountryFlag_NewCaledonia": 850,
+  "CountryFlag_Nicaragua": 175,
+  "CountryFlag_Niger": 459,
+  "CountryFlag_Nigeria": 176,
+  "CountryFlag_Northern_Ireland": 177,
+  "CountryFlag_NorthKorea": 460,
+  "CountryFlag_Norway": 178,
+  "CountryFlag_Oman": 461,
+  "CountryFlag_Pakistan": 179,
+  "CountryFlag_Palau": 851,
+  "CountryFlag_Palestine": 462,
+  "CountryFlag_Panama": 180,
+  "CountryFlag_Papua_NewGuinea": 463,
+  "CountryFlag_Paraguay": 464,
+  "CountryFlag_Peru": 181,
+  "CountryFlag_Philippines": 182,
+  "CountryFlag_Poland": 183,
+  "CountryFlag_Portugal": 184,
+  "CountryFlag_Puerto_Rico": 465,
+  "CountryFlag_Puntland": 565,
+  "CountryFlag_Qatar": 466,
+  "CountryFlag_Reunion": 566,
+  "CountryFlag_Romania": 185,
+  "CountryFlag_Russia": 186,
+  "CountryFlag_Rwanda": 467,
+  "CountryFlag_SaintKitts": 852,
+  "CountryFlag_SaintLucia": 853,
+  "CountryFlag_SaintVincent": 854,
+  "CountryFlag_Samoa": 855,
+  "CountryFlag_SanMarino": 856,
+  "CountryFlag_SaoTome": 857,
+  "CountryFlag_Saudi_Arabia": 468,
+  "CountryFlag_Scotland": 187,
+  "CountryFlag_Senegal": 188,
+  "CountryFlag_Serbia": 189,
+  "CountryFlag_Seychelles": 858,
+  "CountryFlag_Sierra_Leone": 469,
+  "CountryFlag_Singapore": 190,
+  "CountryFlag_Slovakia": 191,
+  "CountryFlag_Slovenia": 192,
+  "CountryFlag_SolomonIslands": 567,
+  "CountryFlag_Somalia": 470,
+  "CountryFlag_Somaliland": 568,
+  "CountryFlag_South_Sudan": 471,
+  "CountryFlag_SouthAfrica": 193,
+  "CountryFlag_SouthKorea": 194,
+  "CountryFlag_Spain": 195,
+  "CountryFlag_Sri_Lanka": 389,
+  "CountryFlag_Sudan": 472,
+  "CountryFlag_Suriname": 569,
+  "CountryFlag_Swaziland": 570,
+  "CountryFlag_Sweden": 196,
+  "CountryFlag_Switzerland": 197,
+  "CountryFlag_Syria": 473,
+  "CountryFlag_Tahiti": 859,
+  "CountryFlag_Taiwan": 198,
+  "CountryFlag_Tajikistan": 474,
+  "CountryFlag_Tanzania": 475,
+  "CountryFlag_Thailand": 199,
+  "CountryFlag_Togo": 476,
+  "CountryFlag_Tonga": 730,
+  "CountryFlag_Trinidad_Tobago": 477,
+  "CountryFlag_Tunisia": 200,
+  "CountryFlag_Turkey": 201,
+  "CountryFlag_Turkmenistan": 478,
+  "CountryFlag_TurksCacos": 860,
+  "CountryFlag_UAE": 202,
+  "CountryFlag_Uganda": 479,
+  "CountryFlag_UK": 203,
+  "CountryFlag_Ukraine": 204,
+  "CountryFlag_Uruguay": 205,
+  "CountryFlag_US_Virgin_Islands": 480,
+  "CountryFlag_USA": 206,
+  "CountryFlag_Uzbekistan": 481,
+  "CountryFlag_Vanuatu": 861,
+  "CountryFlag_VaticanCity": 862,
+  "CountryFlag_Venezuela": 207,
+  "CountryFlag_Vietnam": 208,
+  "CountryFlag_Wales": 209,
+  "CountryFlag_WesternSahara": 571,
+  "CountryFlag_Yemen": 210,
+  "CountryFlag_Zambia": 482,
+  "CountryFlag_Zimbabwe": 483,
+  "Crown_Bronze": 705,
+  "Crown_Gold": 706,
+  "Crown_Platinum": 707,
+  "Crown_Silver": 708,
+  "Flag_4PlayerNetwork": 1212,
+  "Flag_9Gag": 799,
+  "Flag_AbleGamers": 814,
+  "flag_atlanta_hawks": 1245,
+  "Flag_Batman": 804,
+  "Flag_BlacklightRetribution": 211,
+  "flag_boston_celtics": 1246,
+  "flag_brooklyn_nets": 1247,
+  "flag_charlotte_hornets": 1248,
+  "flag_chicago_bulls": 1249,
+  "Flag_Chivalry_Agatha": 525,
+  "Flag_Chivalry_Mason": 526,
+  "flag_cleveland_cavaliers": 1250,
+  "Flag_Community": 527,
+  "flag_dallas_mavericks": 1251,
+  "Flag_Day9TV": 654,
+  "flag_denver_nuggets": 1252,
+  "Flag_Destructoid": 724,
+  "flag_detroit_pistons": 1253,
+  "Flag_DyingLight": 1304,
+  "Flag_EdgeOfSpace": 213,
+  "Flag_Epic_BlueFlag": 484,
+  "Flag_Epic_FragCenter": 485,
+  "Flag_Epic_RedFlag": 486,
+  "Flag_Epic_UT": 487,
+  "Flag_Epic_UT2004": 488,
+  "Flag_Epic_UTBlue": 489,
+  "Flag_FenixRage": 216,
+  "Flag_Gamespot": 725,
+  "Flag_Gfinity": 732,
+  "flag_goldenstate_warriors": 1254,
+  "Flag_GoldGlove": 728,
+  "flag_houston_rockets": 1255,
+  "Flag_IGN": 802,
+  "flag_indiana_pacers": 1256,
+  "flag_la_clippers": 1257,
+  "flag_la_lakers": 1258,
+  "flag_memphis_grizzlies": 1259,
+  "flag_miami_heat": 1260,
+  "flag_minnesota_timberwolves": 1262,
+  "Flag_Mlwaukee_Bucks": 1261,
+  "Flag_Moderator": 528,
+  "flag_nba": 1263,
+  "flag_neogaf": 616,
+  "flag_neworleans_pelicans": 1264,
+  "flag_newyork_knicks": 1265,
+  "flag_nvidia": 808,
+  "Flag_Oddworld_Necrum": 632,
+  "Flag_Oddworld_RF": 633,
+  "flag_oklahomacity_thunder": 1266,
+  "Flag_Operation_Sports": 726,
+  "flag_orlando_magic": 1267,
+  "Flag_PanicButton": 829,
+  "Flag_PCGamer": 667,
+  "flag_philadelphia_76ers": 1268,
+  "flag_phoenix_suns": 1269,
+  "Flag_Pirate": 217,
+  "flag_portland_trailblazers": 1270,
+  "Flag_Rainbow": 490,
+  "Flag_Razer": 573,
+  "Flag_Reddit": 630,
+  "flag_rl_garage": 645,
+  "Flag_RocketBeans": 869,
+  "flag_sacramento_kings": 1271,
+  "flag_sanantonio_spurs": 1272,
+  "Flag_Shacknews": 1213,
+  "Flag_Shadowgate": 218,
+  "Flag_SomethingAwful": 699,
+  "Flag_StrikeVectorEX": 219,
+  "Flag_Superman": 805,
+  "Flag_TeamFat": 220,
+  "flag_toronto_raptors": 1273,
+  "Flag_Translator": 530,
+  "Flag_Twitch": 221,
+  "Flag_Unreal": 222,
+  "Flag_Utah_Jazz": 1274,
+  "flag_valve_ApLabs": 656,
+  "Flag_Valve_Cake": 657,
+  "Flag_VGX": 693,
+  "Flag_Warframe": 223,
+  "flag_washington_wizards": 1275,
+  "Flag_WhiteFlag": 491,
+  "Flag_WonderWoman": 806,
+  "Flag_Worms": 1008,
+  "Hat_AlphaHat": 224,
+  "Hat_Antlers": 774,
+  "Hat_BaseballCap_Back": 1328,
+  "Hat_BaseballCap01": 1305,
+  "Hat_Beanie": 225,
+  "Hat_BeerHelmet": 493,
+  "Hat_Beret": 775,
+  "Hat_BikerHat": 776,
+  "Hat_BirthdayCake": 789,
+  "Hat_BoomBox": 521,
+  "Hat_Boot": 740,
+  "Hat_Bow": 811,
+  "Hat_BPH": 227,
+  "Hat_CaptainHat": 741,
+  "Hat_CatEars": 810,
+  "Hat_CatOLantern": 546,
+  "Hat_Chainsaw": 795,
+  "Hat_ChefsHat": 756,
+  "Hat_Cockroach": 769,
+  "Hat_CowboyHat": 1316,
+  "Hat_CowSkull": 685,
+  "Hat_Crown": 228,
+  "hat_deadmau5": 796,
+  "Hat_Deerstalker": 792,
+  "Hat_DerbyHat": 790,
+  "Hat_DetectiveHat": 742,
+  "Hat_DevilHorns": 229,
+  "HAT_DoughboyHelmet": 743,
+  "Hat_DWI": 494,
+  "Hat_Fez": 230,
+  "Hat_Fireman": 231,
+  "Hat_FlatHat": 1339,
+  "HAT_FruitHat": 744,
+  "hat_futureglasses": 1288,
+  "HAT_GentlemanHat": 745,
+  "Hat_GolfHat": 1326,
+  "Hat_GraduationCap": 778,
+  "Hat_Halo": 232,
+  "Hat_Hardhat": 233,
+  "Hat_LightBulb": 644,
+  "Hat_Mariachi": 234,
+  "Hat_Mohawk": 638,
+  "Hat_Paper_Boat": 770,
+  "Hat_Party": 812,
+  "Hat_Pigeon": 817,
+  "Hat_PirateHat": 235,
+  "Hat_PizzaLight": 236,
+  "Hat_Plunger": 772,
+  "Hat_PoliceHat": 746,
+  "Hat_PoliceSiren": 237,
+  "Hat_PortalCake": 661,
+  "Hat_Rasta": 771,
+  "Hat_RatTrap": 782,
+  "Hat_RegularChristmasTree": 639,
+  "Hat_ReindeerAntlers": 640,
+  "Hat_Rhino": 780,
+  "Hat_SadChristmasTree": 641,
+  "Hat_SantaHat": 642,
+  "Hat_SharkFin": 579,
+  "Hat_Shuriken": 773,
+  "Hat_Sombrero": 238,
+  "Hat_Taxi": 239,
+  "Hat_Tiara": 495,
+  "Hat_TopHat": 240,
+  "Hat_TourneyWinner": 390,
+  "HAT_TrafficCone": 747,
+  "Hat_TrainConductor": 547,
+  "Hat_Tricorn": 580,
+  "Hat_TruckerHat": 1338,
+  "Hat_Unihorn": 748,
+  "Hat_Viking_Helmet": 241,
+  "Hat_Visor": 1342,
+  "Hat_WhiteHat": 1332,
+  "Hat_Witch": 242,
+  "Hat_WizardHat": 243,
+  "Hat_Worms": 1028,
+  "Logo_Barracudas": 244,
+  "Logo_Blacklight": 245,
+  "Logo_Bombers": 246,
+  "Logo_Cyclones": 247,
+  "Logo_Destroyers": 248,
+  "Logo_Dragons": 249,
+  "Logo_Express": 250,
+  "Logo_Guardians": 251,
+  "Logo_Knights": 252,
+  "Logo_Kodiaks": 253,
+  "Logo_Mammoths": 254,
+  "Logo_Monarchs": 255,
+  "Logo_Phantoms": 256,
+  "Logo_Pharaohs": 257,
+  "Logo_Pioneers": 258,
+  "Logo_Ravagers": 259,
+  "Logo_Rebels": 260,
+  "Logo_Rovers": 261,
+  "Logo_Scorpions": 262,
+  "Logo_Skyhawks": 263,
+  "Logo_Wolves": 264,
+  "Pack_CommunityFlags": 496,
+  "Pack_CountryFlags": 265,
+  "pack_nba": 1277,
+  "Pack_VideoGamesFlags": 543,
+  "PaintFinish_BrushedMetal": 266,
+  "PaintFinish_Camo": 541,
+  "PaintFinish_Corroded": 268,
+  "PaintFinish_Cracked": 542,
+  "PaintFinish_Default": 270,
+  "PaintFinish_GlossyCarbonFiber": 272,
+  "PaintFinish_GreyCar": 623,
+  "PaintFinish_Matte": 273,
+  "PaintFinish_Metallic": 274,
+  "PaintFinish_MetallicPearl": 275,
+  "PaintFinish_Pearlescent": 276,
+  "PaintFinish_Plastic": 277,
+  "PaintFinish_ToonGlossy": 279,
+  "PaintFinish_ToonGlossyWood": 280,
+  "PaintFinish_ToonMatte": 281,
+  "PaintFinish_Wood": 283,
+  "Pennant_BlueChecker": 284,
+  "Pennant_Camo": 285,
+  "Pennant_OrangeNylon": 286,
+  "Pennant_Psyonix": 287,
+  "Skin_Aftershock_Aztec": 1306,
+  "Skin_Aftershock_Gato": 1308,
+  "Skin_Aftershock_Radioactive": 1311,
+  "Skin_Aftershock_Shockwave": 1312,
+  "Skin_Aftershock_Tiger": 1313,
+  "Skin_Aftershock_Wings": 1314,
+  "Skin_Backfire_Flames": 288,
+  "Skin_Backfire_Lightning": 289,
+  "Skin_Backfire_Skulls": 290,
+  "Skin_Backfire_Stars": 291,
+  "Skin_Backfire_Stripes": 292,
+  "Skin_Backfire_Tech": 293,
+  "Skin_Backfire_Wings": 294,
+  "Skin_Force_Flames": 295,
+  "Skin_Force_Junk": 1178,
+  "Skin_Force_Lightning": 296,
+  "Skin_Force_Skulls": 297,
+  "Skin_Force_Stars": 298,
+  "Skin_Force_Stripes": 299,
+  "Skin_Force_Tech": 300,
+  "Skin_Force_Wings": 301,
+  "Skin_Import_Gato": 497,
+  "Skin_Import_Nitrous": 498,
+  "Skin_Import_Ohai": 499,
+  "Skin_Import_Stripes": 501,
+  "Skin_Import_Technologic": 502,
+  "Skin_Import_Wisp": 503,
+  "Skin_Interceptor_Flames": 669,
+  "Skin_Interceptor_Getkraken": 700,
+  "Skin_Interceptor_Kitsune": 670,
+  "Skin_Interceptor_Sandgirl": 701,
+  "Skin_Interceptor_Spikes": 671,
+  "Skin_Interceptor_Tribal": 672,
+  "skin_marauder_avalanche": 1345,
+  "Skin_Marauder_BigBuck": 1340,
+  "skin_marauder_gasmask": 1346,
+  "skin_marauder_safari": 1348,
+  "Skin_Marauder_Stripes": 1341,
+  "skin_marauder_wings": 1352,
+  "Skin_MuscleCar_Diammond": 1179,
+  "Skin_MuscleCar_Flames": 504,
+  "Skin_MuscleCar_Skorpion": 506,
+  "Skin_MuscleCar_Skulls": 507,
+  "Skin_MuscleCar_Stripes": 508,
+  "Skin_MuscleCar_Tribal": 509,
+  "Skin_MuscleCar_Wings": 510,
+  "Skin_NeoBike_Cutter": 1320,
+  "Skin_NeoBike_FormulaIII": 1321,
+  "Skin_NeoBike_Kaiju": 1322,
+  "Skin_NeoBike_MouseCat": 1323,
+  "Skin_NeoBike_Neo": 1324,
+  "Skin_NeoBike_Pegasus": 1325,
+  "Skin_NeoCar_Flames": 1173,
+  "Skin_NeoCar_Pill": 1279,
+  "Skin_NeoCar_Squid": 1280,
+  "Skin_NeoCar_Stars": 1174,
+  "Skin_NeoCar_Stripes": 1175,
+  "Skin_NeoCar_Track": 1176,
+  "Skin_Octane_Flames": 302,
+  "Skin_Octane_Lightning": 303,
+  "Skin_Octane_Racer": 1065,
+  "Skin_Octane_Skulls": 304,
+  "Skin_Octane_Stars": 305,
+  "Skin_Octane_Stripes": 306,
+  "Skin_Octane_Tech": 307,
+  "Skin_Octane_Wings": 308,
+  "Skin_Orion_Flames": 309,
+  "Skin_Orion_Lightning": 310,
+  "Skin_Orion_Skulls": 311,
+  "Skin_Orion_Stars": 312,
+  "Skin_Orion_Stripes": 313,
+  "Skin_Orion_Tech": 314,
+  "Skin_Orion_Wings": 315,
+  "Skin_Rhino_Flames": 316,
+  "Skin_Rhino_Lightning": 317,
+  "Skin_Rhino_Skulls": 318,
+  "Skin_Rhino_Stars": 319,
+  "Skin_Rhino_Stripes": 320,
+  "Skin_Rhino_TechSplat": 321,
+  "Skin_Rhino_Wings": 322,
+  "Skin_Scarab_Clouds": 585,
+  "Skin_Scarab_Flames": 591,
+  "Skin_Scarab_Hearts": 586,
+  "Skin_Scarab_RollerDerby": 592,
+  "Skin_Scarab_Tiger": 587,
+  "Skin_Scarab_Tribal": 588,
+  "Skin_Spark_Flames": 323,
+  "Skin_Spark_Lightning": 324,
+  "Skin_Spark_Skulls": 325,
+  "Skin_Spark_Stars": 326,
+  "Skin_Spark_Stripes": 327,
+  "Skin_Spark_Tech": 328,
+  "Skin_Spark_Wings": 329,
+  "Skin_Torch_Flames": 330,
+  "Skin_Torch_Lightning": 331,
+  "Skin_Torch_Skulls": 332,
+  "Skin_Torch_Stars": 333,
+  "Skin_Torch_Stripes": 334,
+  "Skin_Torch_Tech": 335,
+  "Skin_Torch_Wings": 336,
+  "Skin_Torment_Flames": 337,
+  "Skin_Torment_Lightning": 338,
+  "Skin_Torment_Skulls": 339,
+  "Skin_Torment_Stars": 340,
+  "Skin_Torment_Stripes": 341,
+  "Skin_Torment_Tech": 342,
+  "Skin_Torment_Wings": 343,
+  "Skin_Vanquish_Dots": 344,
+  "Skin_Vanquish_Flames": 345,
+  "Skin_Vanquish_Lightning": 346,
+  "Skin_Vanquish_Skulls": 347,
+  "Skin_Vanquish_Stars": 348,
+  "Skin_Vanquish_Stripes": 349,
+  "Skin_Vanquish_Wings": 350,
+  "Skin_Venom_Dots": 351,
+  "Skin_Venom_Flames": 352,
+  "Skin_Venom_Flex": 1084,
+  "Skin_Venom_Lightning": 353,
+  "Skin_Venom_Skulls": 354,
+  "Skin_Venom_Stripes": 355,
+  "Skin_Venom_Tagged": 356,
+  "Skin_Venom_Wings": 357,
+  "Skin_WastelandTruck_Bomber": 674,
+  "Skin_WastelandTruck_Bunny": 675,
+  "Skin_WastelandTruck_Cyclops": 694,
+  "Skin_WastelandTruck_Stripe": 687,
+  "Skin_WastelandTruck_Tagged": 695,
+  "Skin_WastelandTruck_Tribal": 696,
+  "Skin_Zippy_Flames": 583,
+  "Skin_Zippy_Hearts": 581,
+  "Skin_Zippy_HeckTec": 589,
+  "Skin_Zippy_Leopard": 584,
+  "Skin_Zippy_MeanMachine": 590,
+  "Skin_Zippy_Tiger": 582,
+  "StreamerFlag_Ach_Hunter": 610,
+  "StreamerFlag_AgryJoe": 548,
+  "StreamerFlag_Annemunition": 531,
+  "StreamerFlag_EatMyDiction": 532,
+  "StreamerFlag_Funhaus": 611,
+  "StreamerFlag_GassyMexican": 511,
+  "StreamerFlag_ITMEJP": 533,
+  "StreamerFlag_Lirik": 513,
+  "StreamerFlag_Lol_Renaynay": 628,
+  "StreamerFlag_Polaris": 608,
+  "StreamerFlag_Rooster_Teeth": 612,
+  "StreamerFlag_SaudiGamer": 534,
+  "StreamerFlag_SeriousGaming": 572,
+  "StreamerFlag_Spooky1611": 514,
+  "StreamerFlag_Team_PowA": 515,
+  "StreamerFlag_TheAttack": 535,
+  "StreamerFlag_Youtube": 516,
+  "TourneyFlag_ESL_Champ": 537,
+  "TourneyFlag_ESL": 536,
+  "TourneyFlag_MLG": 538,
+  "TourneyFlag_TourneyChamp": 539,
+  "WHEEL_Aftershock": 1315,
+  "WHEEL_AlphaRim": 358,
+  "WHEEL_Armadillo": 618,
+  "WHEEL_Atlantis": 359,
+  "WHEEL_Ballistic": 360,
+  "WHEEL_Bling": 361,
+  "WHEEL_Brink": 362,
+  "WHEEL_Caliber": 363,
+  "WHEEL_Carriage": 549,
+  "WHEEL_Cog": 716,
+  "WHEEL_Crypt": 364,
+  "WHEEL_Darkcar": 874,
+  "WHEEL_DeepDish": 365,
+  "WHEEL_Dynamo": 366,
+  "WHEEL_Forge": 367,
+  "WHEEL_GreyCar": 609,
+  "WHEEL_Hydra": 368,
+  "WHEEL_LowRider": 369,
+  "Wheel_Marauder": 1343,
+  "WHEEL_Mob": 370,
+  "WHEEL_Neo": 797,
+  "WHEEL_NeoWheel": 1177,
+  "Wheel_Ninja": 371,
+  "WHEEL_OffRoad": 372,
+  "Wheel_Pedigree": 373,
+  "WHEEL_Revolution": 374,
+  "WHEEL_Scarab": 540,
+  "WHEEL_SingleSpoke": 518,
+  "WHEEL_SkullxBones": 519,
+  "WHEEL_SnowTire": 375,
+  "Wheel_SoccerBall": 386,
+  "WHEEL_Spiked": 690,
+  "Wheel_Spinner": 388,
+  "WHEEL_Star": 376,
+  "WHEEL_Storm": 377,
+  "WHEEL_SweetTooth": 378,
+  "WHEEL_Tarantula": 379,
+  "WHEEL_Triad": 380,
+  "WHEEL_Vortex": 381,
+  "Wheel_Vulcan": 382,
+  "WHEEL_Warthog": 719,
+  "Wheel_WastelandTruck": 613,
+  "WHEEL_Wynd": 383
+}
diff --git a/data/properties.json b/data/properties.json
new file mode 100644
--- /dev/null
+++ b/data/properties.json
@@ -0,0 +1,128 @@
+{
+  "Engine.Actor:bBlockActors": "boolean",
+  "Engine.Actor:bCollideActors": "boolean",
+  "Engine.Actor:bCollideWorld": "boolean",
+  "Engine.Actor:bHardAttach": "boolean",
+  "Engine.Actor:bHidden": "boolean",
+  "Engine.Actor:bNetOwner": "boolean",
+  "Engine.Actor:bProjTarget": "boolean",
+  "Engine.Actor:bTearOff": "boolean",
+  "Engine.Actor:DrawScale": "float",
+  "Engine.Actor:Owner": "flagged_int",
+  "Engine.Actor:RelativeLocation": "location",
+  "Engine.Actor:RelativeRotation": "relative_rotation",
+  "Engine.Actor:ReplicatedCollisionType": "flagged_int",
+  "Engine.Actor:Role": "enum",
+  "Engine.GameReplicationInfo:bMatchIsOver": "boolean",
+  "Engine.GameReplicationInfo:GameClass": "flagged_int",
+  "Engine.GameReplicationInfo:ServerName": "string",
+  "Engine.Pawn:bCanSwatTurn": "boolean",
+  "Engine.Pawn:bRootMotionFromInterpCurve": "boolean",
+  "Engine.Pawn:bSimulateGravity": "boolean",
+  "Engine.Pawn:PlayerReplicationInfo": "flagged_int",
+  "Engine.PlayerReplicationInfo:bAdmin": "boolean",
+  "Engine.PlayerReplicationInfo:bBot": "boolean",
+  "Engine.PlayerReplicationInfo:bFromPreviousLevel": "boolean",
+  "Engine.PlayerReplicationInfo:bIsSpectator": "boolean",
+  "Engine.PlayerReplicationInfo:bOnlySpectator": "boolean",
+  "Engine.PlayerReplicationInfo:bOutOfLives": "boolean",
+  "Engine.PlayerReplicationInfo:bReadyToPlay": "boolean",
+  "Engine.PlayerReplicationInfo:bWaitingPlayer": "boolean",
+  "Engine.PlayerReplicationInfo:Ping": "byte",
+  "Engine.PlayerReplicationInfo:PlayerID": "int",
+  "Engine.PlayerReplicationInfo:PlayerName": "string",
+  "Engine.PlayerReplicationInfo:RemoteUserData": "string",
+  "Engine.PlayerReplicationInfo:Score": "int",
+  "Engine.PlayerReplicationInfo:Team": "flagged_int",
+  "Engine.PlayerReplicationInfo:UniqueId": "unique_id",
+  "Engine.TeamInfo:Score": "int",
+  "Engine.TeamInfo:TeamIndex": "flagged_int",
+  "ProjectX.GRI_X:bGameStarted": "boolean",
+  "ProjectX.GRI_X:GameServerID": "qword",
+  "ProjectX.GRI_X:ReplicatedGameMutatorIndex": "int",
+  "ProjectX.GRI_X:ReplicatedGamePlaylist": "int",
+  "ProjectX.GRI_X:Reservations": "reservation",
+  "TAGame.Ball_TA:GameEvent": "flagged_int",
+  "TAGame.Ball_TA:HitTeamNum": "byte",
+  "TAGame.Ball_TA:ReplicatedAddedCarBounceScale": "float",
+  "TAGame.Ball_TA:ReplicatedBallMaxLinearSpeedScale": "float",
+  "TAGame.Ball_TA:ReplicatedBallScale": "float",
+  "TAGame.Ball_TA:ReplicatedExplosionData": "explosion",
+  "TAGame.Ball_TA:ReplicatedWorldBounceScale": "float",
+  "TAGame.CameraSettingsActor_TA:bUsingBehindView": "boolean",
+  "TAGame.CameraSettingsActor_TA:bUsingSecondaryCamera": "boolean",
+  "TAGame.CameraSettingsActor_TA:CameraPitch": "byte",
+  "TAGame.CameraSettingsActor_TA:CameraYaw": "byte",
+  "TAGame.CameraSettingsActor_TA:PRI": "flagged_int",
+  "TAGame.CameraSettingsActor_TA:ProfileSettings": "cam_settings",
+  "TAGame.Car_TA:ReplicatedDemolish": "demolish",
+  "TAGame.Car_TA:TeamPaint": "team_paint",
+  "TAGame.CarComponent_Boost_TA:bNoBoost": "boolean",
+  "TAGame.CarComponent_Boost_TA:BoostModifier": "float",
+  "TAGame.CarComponent_Boost_TA:bUnlimitedBoost": "boolean",
+  "TAGame.CarComponent_Boost_TA:RechargeDelay": "float",
+  "TAGame.CarComponent_Boost_TA:RechargeRate": "float",
+  "TAGame.CarComponent_Boost_TA:ReplicatedBoostAmount": "byte",
+  "TAGame.CarComponent_Dodge_TA:DodgeTorque": "location",
+  "TAGame.CarComponent_FlipCar_TA:bFlipRight": "boolean",
+  "TAGame.CarComponent_FlipCar_TA:FlipCarTime": "float",
+  "TAGame.CarComponent_TA:ReplicatedActive": "byte",
+  "TAGame.CarComponent_TA:Vehicle": "flagged_int",
+  "TAGame.CrowdActor_TA:GameEvent": "flagged_int",
+  "TAGame.CrowdActor_TA:ModifiedNoise": "float",
+  "TAGame.CrowdActor_TA:ReplicatedCountDownNumber": "int",
+  "TAGame.CrowdActor_TA:ReplicatedOneShotSound": "flagged_int",
+  "TAGame.CrowdManager_TA:GameEvent": "flagged_int",
+  "TAGame.CrowdManager_TA:ReplicatedGlobalOneShotSound": "flagged_int",
+  "TAGame.GameEvent_Soccar_TA:bBallHasBeenHit": "boolean",
+  "TAGame.GameEvent_Soccar_TA:bOverTime": "boolean",
+  "TAGame.GameEvent_Soccar_TA:ReplicatedMusicStinger": "music_stinger",
+  "TAGame.GameEvent_Soccar_TA:ReplicatedScoredOnTeam": "byte",
+  "TAGame.GameEvent_Soccar_TA:RoundNum": "int",
+  "TAGame.GameEvent_Soccar_TA:SecondsRemaining": "int",
+  "TAGame.GameEvent_SoccarPrivate_TA:MatchSettings": "private_match_settings",
+  "TAGame.GameEvent_TA:bHasLeaveMatchPenalty": "boolean",
+  "TAGame.GameEvent_TA:BotSkill": "int",
+  "TAGame.GameEvent_TA:GameMode": "game_mode",
+  "TAGame.GameEvent_TA:ReplicatedGameStateTimeRemaining": "int",
+  "TAGame.GameEvent_TA:ReplicatedStateIndex": "byte",
+  "TAGame.GameEvent_TA:ReplicatedStateName": "int",
+  "TAGame.GameEvent_Team_TA:bDisableMutingOtherTeam": "boolean",
+  "TAGame.GameEvent_Team_TA:MaxTeamSize": "int",
+  "TAGame.GRI_TA:NewDedicatedServerIP": "string",
+  "TAGame.PRI_TA:bIsInSplitScreen": "boolean",
+  "TAGame.PRI_TA:bMatchMVP": "boolean",
+  "TAGame.PRI_TA:bOnlineLoadoutSet": "boolean",
+  "TAGame.PRI_TA:bReady": "boolean",
+  "TAGame.PRI_TA:bUsingBehindView": "boolean",
+  "TAGame.PRI_TA:bUsingFreecam": "boolean",
+  "TAGame.PRI_TA:bUsingSecondaryCamera": "boolean",
+  "TAGame.PRI_TA:bVoteToForfeitDisabled": "boolean",
+  "TAGame.PRI_TA:CameraPitch": "byte",
+  "TAGame.PRI_TA:CameraSettings": "cam_settings",
+  "TAGame.PRI_TA:CameraYaw": "byte",
+  "TAGame.PRI_TA:ClientLoadout": "loadout",
+  "TAGame.PRI_TA:ClientLoadoutOnline": "loadout_online",
+  "TAGame.PRI_TA:MatchAssists": "int",
+  "TAGame.PRI_TA:MatchGoals": "int",
+  "TAGame.PRI_TA:MatchSaves": "int",
+  "TAGame.PRI_TA:MatchScore": "int",
+  "TAGame.PRI_TA:MatchShots": "int",
+  "TAGame.PRI_TA:PartyLeader": "party_leader",
+  "TAGame.PRI_TA:PersistentCamera": "flagged_int",
+  "TAGame.PRI_TA:ReplicatedGameEvent": "flagged_int",
+  "TAGame.PRI_TA:Title": "int",
+  "TAGame.PRI_TA:TotalXP": "int",
+  "TAGame.RBActor_TA:bFrozen": "boolean",
+  "TAGame.RBActor_TA:bReplayActor": "boolean",
+  "TAGame.RBActor_TA:ReplicatedRBState": "rigid_body_state",
+  "TAGame.Team_Soccar_TA:GameScore": "int",
+  "TAGame.Team_TA:CustomTeamName": "string",
+  "TAGame.Team_TA:GameEvent": "flagged_int",
+  "TAGame.Team_TA:LogoData": "flagged_int",
+  "TAGame.Vehicle_TA:bDriving": "boolean",
+  "TAGame.Vehicle_TA:bReplicatedHandbrake": "boolean",
+  "TAGame.Vehicle_TA:ReplicatedSteer": "byte",
+  "TAGame.Vehicle_TA:ReplicatedThrottle": "byte",
+  "TAGame.VehiclePickup_TA:ReplicatedPickupData": "pickup"
+}
diff --git a/library/Octane/Data.hs b/library/Octane/Data.hs
--- a/library/Octane/Data.hs
+++ b/library/Octane/Data.hs
@@ -1,27 +1,68 @@
-module Octane.Data
-    ( module Octane.Data.Antennas
-    , module Octane.Data.Bodies
-    , module Octane.Data.Bots
-    , module Octane.Data.Classes
-    , module Octane.Data.Decals
-    , module Octane.Data.Finishes
-    , module Octane.Data.GameModes
-    , module Octane.Data.Logos
-    , module Octane.Data.Properties
-    , module Octane.Data.RocketTrails
-    , module Octane.Data.Toppers
-    , module Octane.Data.Wheels
-    ) where
+{-# LANGUAGE TemplateHaskell #-}
 
-import Octane.Data.Antennas
-import Octane.Data.Bodies
-import Octane.Data.Bots
-import Octane.Data.Classes
-import Octane.Data.Decals
-import Octane.Data.Finishes
-import Octane.Data.GameModes
-import Octane.Data.Logos
-import Octane.Data.Properties
-import Octane.Data.RocketTrails
-import Octane.Data.Toppers
-import Octane.Data.Wheels
+module Octane.Data where
+
+import qualified Data.Bimap as Bimap
+import qualified Data.FileEmbed as FileEmbed
+import qualified Data.Map.Strict as Map
+import qualified Data.Set as Set
+import qualified Data.Text as StrictText
+import qualified Octane.Utility.Embed as Embed
+
+-- $setup
+-- >>> :set -XOverloadedStrings
+
+
+-- | A map from object names to their class names.
+--
+-- >>> Map.lookup "Archetypes.Ball.Ball_Default" classes
+-- Just "TAGame.Ball_TA"
+--
+-- Note that some object names have been normalized to make lookup easier.
+--
+-- >>> Map.lookup "Neotokyo_p.TheWorld:PersistentLevel.InMapScoreboard_TA_0@" classes
+-- Nothing
+-- >>> Map.lookup "TheWorld:PersistentLevel.InMapScoreboard_TA" classes
+-- Just "TAGame.InMapScoreboard_TA"
+classes :: Map.Map StrictText.Text StrictText.Text
+classes = Embed.decodeMap $(FileEmbed.embedFile "data/classes.json")
+
+
+-- | A set of classes that have an initial location vector.
+--
+-- >>> Set.member "TAGame.Ball_TA" classesWithLocation
+-- True
+classesWithLocation :: Set.Set StrictText.Text
+classesWithLocation = Embed.decodeSet $(FileEmbed.embedFile "data/classes-with-location.json")
+
+
+-- | A set of classes that have an initial rotation vector.
+--
+-- >>> Set.member "TAGame.Ball_TA" classesWithRotation
+-- True
+classesWithRotation :: Set.Set StrictText.Text
+classesWithRotation = Embed.decodeSet $(FileEmbed.embedFile "data/classes-with-rotation.json")
+
+
+-- | A one-to-one mapping between game mode IDs and their names.
+--
+-- >>> Bimap.lookup 1 gameModes :: Maybe StrictText.Text
+-- Just "Hockey"
+gameModes :: Bimap.Bimap Int StrictText.Text
+gameModes = Embed.decodeBimap $(FileEmbed.embedFile "data/game-modes.json")
+
+
+-- | A one-to-one mapping between product IDs and their names.
+--
+-- >>> Bimap.lookup 1 products :: Maybe StrictText.Text
+-- Just "Antenna_8Ball"
+products :: Bimap.Bimap Word StrictText.Text
+products = Embed.decodeBimap $(FileEmbed.embedFile "data/products.json")
+
+
+-- | A mapping between property names and their serialized type.
+--
+-- >>> Map.lookup "Engine.Actor:bBlockActors" properties
+-- Just "boolean"
+properties :: Map.Map StrictText.Text StrictText.Text
+properties = Embed.decodeMap $(FileEmbed.embedFile "data/properties.json")
diff --git a/library/Octane/Data/Antennas.hs b/library/Octane/Data/Antennas.hs
deleted file mode 100644
--- a/library/Octane/Data/Antennas.hs
+++ /dev/null
@@ -1,435 +0,0 @@
-module Octane.Data.Antennas (antennas) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between antenna IDs and their names.
-antennas :: Bimap.Bimap Int StrictText.Text
-antennas =
-    [ ("None", 0)
-    , ("(Beta Reward) Gold Nugget", 6)
-    , ("8-Ball", 1)
-    , ("Alien", 753)
-    , ("Balloon Dog", 752)
-    , ("Batman", 804)
-    , ("Blue Chequered Flag", 284)
-    , ("Bomb Pole", 614)
-    , ("Calavera", 574) -- DOTDSkull
-    , ("Camo Flag", 285)
-    , ("Candle", 757)
-    , ("Candy Cane", 649)
-    , ("Chick Magnet", 392)
-    , ("Cupcake", 393)
-    , ("Dave's Bread", 391)
-    , ("deadmau5", 781) -- Mau5
-    , ("Disco Ball", 800)
-    , ("Disconnect", 394)
-    , ("Dollar Sign", 3)
-    , ("Donut", 395)
-    , ("Foam Finger", 398)
-    , ("Fuzzy Brute", 593) -- FurballFrakenstein
-    , ("Fuzzy Vamp", 595) -- FurballVampire
-    , ("Genie Lamp", 798)
-    , ("Gingerbread Man", 5)
-    , ("Heart", 7)
-    , ("Holiday Gift", 650) -- ChristmasGift
-    , ("Horseshoe", 8)
-    , ("Hula Girl", 762)
-    , ("Jolly Roger", 217) -- Pirate
-    , ("Lightning Bolt", 9)
-    , ("Moai", 522)
-    , ("Parrot", 750)
-    , ("Peace", 11)
-    , ("Piñata", 400)
-    , ("Planetoid", 12) -- Saturn
-    , ("Radioactive", 615) -- RadiationSymbol
-    , ("Rainbow", 490)
-    , ("Reddit Snoo", 629)
-    , ("Retro Ball - Urban", 575)
-    , ("Retro Ball - Utopia", 576)
-    , ("Retro Ball - Wasteland", 577)
-    , ("Rocket", 755)
-    , ("Rose", 785)
-    , ("Rubber Duckie", 779)
-    , ("Safety Flag", 286) -- OrangeNylon
-    , ("Skull", 13)
-    , ("Smiley", 14)
-    , ("Snowman", 15)
-    , ("Soccer Ball", 16)
-    , ("Star", 17)
-    , ("Sunflower", 786)
-    , ("Superman", 805)
-    , ("Sweet Tooth", 18)
-    , ("Tennis Ball", 19)
-    , ("The Game Awards - Statue", 655) -- VGA_Statue
-    , ("UFO", 20)
-    , ("Venus Flytrap", 788)
-    , ("Waffle", 401)
-    , ("White Flag", 491)
-    , ("Wonder Woman", 806)
-    -- TODO: What are these called in game?
-    , ("Community", 527)
-    , ("ESL_Champ", 537)
-    , ("Eyeball", 396)
-    , ("Moderator", 528)
-    , ("PanicButton", 829)
-    , ("Psyonix", 287)
-    , ("TourneyChamp", 539)
-    , ("Translator", 530)
-    ]
-        ++ communityFlags
-        ++ countryFlags
-        ++ videoGames
-        ++ nbaFlags
-        & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
-
-
-communityFlags :: [(String, Int)]
-communityFlags =
-    [ ("9GAG", 799)
-    , ("AbleGamers", 814)
-    , ("Achievement Hunter", 610)
-    , ("Angry Army", 548) -- AgryJoe
-    , ("Anne Munition", 531)
-    , ("Day[9]TV", 654)
-    , ("Destructoid", 724)
-    , ("EMD1", 532) -- EatMyDiction
-    , ("ESL", 536)
-    , ("Funhaus", 611)
-    , ("Gamespot", 725)
-    , ("Gassy Mexican", 511)
-    , ("Gfinity", 732)
-    , ("GoldGlove", 728)
-    , ("IGN", 802)
-    , ("itmeJP", 533)
-    , ("LIRIK", 513)
-    , ("lolRenaynay", 628)
-    , ("MLG", 538)
-    , ("NeoGAF", 616)
-    , ("NVIDIA", 808)
-    , ("Operation Sports", 726)
-    , ("PC Gamer", 667)
-    , ("Polaris", 608)
-    , ("Razer", 573)
-    , ("Reddit", 630)
-    , ("RL Garage", 645)
-    , ("Rocket Beans TV", 869)
-    , ("Rooster Teeth", 612)
-    , ("Saudi Gamer", 534)
-    , ("Serious Gaming", 572)
-    , ("Something Awful", 699)
-    , ("Spooky1611", 514)
-    , ("Team Fat", 220)
-    , ("Team PowA", 515)
-    , ("The Attack", 535)
-    , ("The Game Awards - Logo", 693) -- VGX
-    , ("Twitch", 221)
-    , ("YouTube Gaming", 516)
-    ]
-
-
-countryFlags :: [(String, Int)]
-countryFlags =
-    [ ("Afghanistan", 407)
-    , ("Albania", 408)
-    , ("Algeria", 124)
-    , ("American Samoa", 409)
-    , ("Andorra", 830)
-    , ("Angola", 410)
-    , ("Anguilla", 831)
-    , ("Antigua & Barbuda", 832)
-    , ("Argentina", 125)
-    , ("Armenia", 411)
-    , ("Aruba", 833)
-    , ("Australia", 126)
-    , ("Austria", 127)
-    , ("Azerbaijan", 412)
-    , ("Bahamas", 834)
-    , ("Bahrain", 550)
-    , ("Bangladesh", 413)
-    , ("Barbados", 835)
-    , ("Belarus", 414)
-    , ("Belgium", 128)
-    , ("Belize", 551)
-    , ("Benin", 415)
-    , ("Bermuda", 836)
-    , ("Bhutan", 552)
-    , ("Bolivia", 416)
-    , ("Bosnia and Herzegovina", 129)
-    , ("Botswana", 553)
-    , ("Brazil", 130)
-    , ("British Virgin Islands", 837)
-    , ("Bulgaria", 131)
-    , ("Burkina Faso", 417)
-    , ("Burma", 418)
-    , ("Burundi", 419)
-    , ("Cambodia", 420)
-    , ("Cameroon", 132)
-    , ("Canada", 133)
-    , ("Cape Verde Islands", 134)
-    , ("Cayman Islands", 838)
-    , ("Central African Republic", 540)
-    , ("Chad", 422)
-    , ("Chile", 135)
-    , ("China", 136)
-    , ("Chinese Taipei", 198)
-    , ("Colombia", 137)
-    , ("Comoros", 554)
-    , ("Congo", 138)
-    , ("Congo DR", 423)
-    , ("Cook Islands", 839)
-    , ("Costa Rica", 139)
-    , ("Croatia", 140)
-    , ("Cuba", 424)
-    , ("Curacao", 840)
-    , ("Cyprus", 141)
-    , ("Czech Republic", 142)
-    , ("Côte d'Ivoire", 165)
-    , ("Denmark", 143)
-    , ("Djibouti", 555)
-    , ("Dominica", 841)
-    , ("Dominican Republic", 425)
-    , ("East Timor", 556)
-    , ("Ecuador", 144)
-    , ("Egypt", 426)
-    , ("El Salvador", 145)
-    , ("England", 146)
-    , ("Equatorial Guinea", 147)
-    , ("Eritrea", 427)
-    , ("Estonia", 428)
-    , ("Ethiopia", 429)
-    , ("Falkland Islands", 842)
-    , ("Faroe Islands", 843)
-    , ("Fiji", 557)
-    , ("Finland", 148)
-    , ("France", 149)
-    , ("Gabon", 430)
-    , ("Gambia", 558)
-    , ("Georgia", 431)
-    , ("Germany", 150)
-    , ("Ghana", 151)
-    , ("Gibraltar", 844)
-    , ("Greece", 152)
-    , ("Grenada", 863)
-    , ("Guam", 432)
-    , ("Guatemala", 153)
-    , ("Guinea", 154)
-    , ("Guinea Bissau", 559)
-    , ("Guyana", 560)
-    , ("Haiti", 433)
-    , ("Honduras", 155)
-    , ("Hong Kong", 156)
-    , ("Hungary", 157)
-    , ("Iceland", 158)
-    , ("India", 159)
-    , ("Indonesia", 160)
-    , ("Iran", 161)
-    , ("Iraq", 434)
-    , ("Ireland", 162)
-    , ("Isle Of Man", 845)
-    , ("Israel", 163)
-    , ("Italy", 164)
-    , ("Jamaica", 166)
-    , ("Japan", 167)
-    , ("Jordan", 435)
-    , ("Kazakhstan", 436)
-    , ("Kenya", 168)
-    , ("Kiribati", 846)
-    , ("Kosovo", 561)
-    , ("Kuwait", 437)
-    , ("Kyrgyzstan", 438)
-    , ("Laos", 439)
-    , ("Latvia", 440)
-    , ("Lebanon", 441)
-    , ("Lesotho", 562)
-    , ("Liberia", 442)
-    , ("Libya", 443)
-    , ("Lithuania", 444)
-    , ("Luxembourg", 169)
-    , ("Macau", 563)
-    , ("Macedonia", 445)
-    , ("Madagascar", 446)
-    , ("Malawi", 447)
-    , ("Malaysia", 170)
-    , ("Maldives", 847)
-    , ("Mali", 448)
-    , ("Malta", 171)
-    , ("Marshall Islands", 848)
-    , ("Mauritania", 449)
-    , ("Mauritius", 564)
-    , ("Mexico", 172)
-    , ("Micronesia", 849)
-    , ("Moldova", 450)
-    , ("Mongolia", 451)
-    , ("Montenegro", 452)
-    , ("Montserrat", 864)
-    , ("Morocco", 453)
-    , ("Mozambique", 454)
-    , ("Namibia", 457)
-    , ("Nepal", 458)
-    , ("Netherlands", 173)
-    , ("New Caledonia", 850)
-    , ("New Zealand", 174)
-    , ("Nicaragua", 175)
-    , ("Nigeria", 176)
-    , ("North Korea", 460)
-    , ("North Mariana Islands", 456)
-    , ("Northern Ireland", 177)
-    , ("Norway", 178)
-    , ("Oman", 461)
-    , ("Palestine", 462)
-    , ("Palau", 851)
-    , ("Palestine", 462)
-    , ("Panama", 180)
-    , ("Papua New Guinea", 463)
-    , ("Paraguay", 464)
-    , ("Peru", 181)
-    , ("Philippines", 182)
-    , ("Poland", 183)
-    , ("Portugal", 184)
-    , ("Puerto Rico", 465)
-    , ("Puntland", 565)
-    , ("Qatar", 466)
-    , ("Republic of Niger", 459)
-    , ("Romania", 185)
-    , ("Russia", 186)
-    , ("Rwanda", 467)
-    , ("Réunion", 566)
-    , ("Saint Kitts & Nevis", 852)
-    , ("Saint Lucia", 853)
-    , ("Saint Vincent", 854)
-    , ("Samoa", 855)
-    , ("San Marino", 856)
-    , ("Sao Tome", 857)
-    , ("Saudi Arabia", 468)
-    , ("Scotland", 187)
-    , ("Senegal", 188)
-    , ("Serbia", 189)
-    , ("Seychelles", 858)
-    , ("Sierra Leone", 469)
-    , ("Singapore", 190)
-    , ("Slovakia", 191)
-    , ("Slovenia", 192)
-    , ("Solomon Islands", 567)
-    , ("Somalia", 470)
-    , ("Somaliland", 568)
-    , ("South Africa", 193)
-    , ("South Korea", 194)
-    , ("South Sudan", 471)
-    , ("Spain", 195)
-    , ("Sri Lanka", 389)
-    , ("Sudan", 472)
-    , ("Suriname", 569)
-    , ("Swaziland", 570)
-    , ("Sweden", 196)
-    , ("Switzerland", 197)
-    , ("Syria", 473)
-    , ("Tahiti", 859)
-    , ("Tajikistan", 474)
-    , ("Tanzania", 475)
-    , ("Thailand", 199)
-    , ("Togo", 476)
-    , ("Tonga", 730)
-    , ("Trinidad and Tobago", 477)
-    , ("Tunisia", 200)
-    , ("Turkey", 201)
-    , ("Turkmenistan", 478)
-    , ("Turks & Caicos", 860)
-    , ("Uganda", 479)
-    , ("Ukraine", 204)
-    , ("United Arab Emirates", 202)
-    , ("United Kingdom", 203)
-    , ("United States", 206)
-    , ("Uruguay", 502)
-    , ("US Virgin Islands", 480)
-    , ("Uzbekistan", 481)
-    , ("Vanuatu", 861)
-    , ("Vatican City", 862)
-    , ("Venezuela", 207)
-    , ("Vietnam", 208)
-    , ("Wales", 209)
-    , ("Western Sahara", 571)
-    , ("Yemen", 210)
-    , ("Zambia", 482)
-    , ("Zimbabwe", 483)
-    ]
-
-
-nbaFlags :: [(String, Int)]
-nbaFlags =
-    [ ("NBA", 1263)
-    , ("Atlanta Hawks", 1245)
-    , ("Boston Celtics", 1246)
-    , ("Brooklyn Nets", 1247)
-    , ("Charlotte Hornets", 1248)
-    , ("Chicago Bulls", 1249)
-    , ("Cleveland Cavaliers", 1250)
-    , ("Dallas Mavericks", 1251)
-    , ("Denver Nuggets", 1252)
-    , ("Detroit Pistons", 1253)
-    , ("Golden State Warriors", 1254)
-    , ("Houston Rockets", 1255)
-    , ("Indian Pacers", 1256)
-    , ("Los Angeles Clippers", 1257)
-    , ("Los Angeles Lakers", 1258)
-    , ("Memphis Grizzlies", 1259)
-    , ("Miami Heat", 1260)
-    , ("Milwaukee Bucks", 1261)
-    , ("Minnesota Timberwolves", 1262)
-    , ("New Orleans Pelicans", 1264)
-    , ("New Tork Knicks", 1265)
-    , ("Oklahoma City Thunder", 1266)
-    , ("Orlando Magic", 1267)
-    , ("Philadelphia 76ers", 1268)
-    , ("Phoenix Suns", 1269)
-    , ("Portland Trail Blazers", 1270)
-    , ("Sacramento Kings", 1271)
-    , ("San Antonio Spurs", 1272)
-    , ("Toronto Raptors", 1273)
-    , ("Utah Jazz", 1274)
-    , ("Washington Wizards", 1275)
-    ]
-
-
-videoGames :: [(String, Int)]
-videoGames =
-    [ ("Blacklight", 385)
-    , ("Blacklight: Retribution", 211)
-    , ("Chivalry - Agatha Knights", 525)
-    , ("Chivalry - Mason Order", 526)
-    , ("Edge Of Space", 213)
-    , ("Euro Truck Simulator Rig", 813) -- ETS2
-    , ("Fallout - Vault Boy", 673)
-    , ("Fenix Rage", 216)
-    , ("Goat Simulator - G2", 927)
-    , ("Goat Simulator - Goatenna", 867)
-    , ("Oddworld - Abe", 678)
-    , ("Oddworld - Molluck", 679)
-    , ("Oddworld - Necrum", 632)
-    , ("Oddworld - RuptureFarms", 633)
-    , ("Portal - Aperture Laboratories", 656)
-    , ("Portal - Cake Sticker", 657)
-    , ("Portal - Companion Cube", 681)
-    , ("Portal - PotatOS", 698)
-    , ("Portal - Wheatley", 686) -- Portal_PC
-    , ("Shadowgate", 218)
-    , ("Strike Vector EX", 219)
-    , ("Unreal", 222)
-    , ("Unreal Frag Center", 485)
-    , ("Unreal Tournament", 489) -- UTBlue
-    , ("Unreal Tournament (Classic)", 487) -- UT
-    , ("Unreal Tournament - Blue", 484) -- Epic_BlueFlag
-    , ("Unreal Tournament - Flak Shell", 397)
-    , ("Unreal Tournament - Red", 486) -- Epic_RedFlag
-    , ("Unreal Tournament 2004", 488)
-    , ("Warframe", 223)
-    , ("Warframe - Chroma", 682)
-    , ("Warframe - Excalibur", 684)
-    , ("Warframe - Loki", 683)
-    , ("Witcher Medallion", 749)
-    , ("Worms W.M.D", 1008)
-    , ("Worms W.M.D. Grenade", 1000)
-    ]
diff --git a/library/Octane/Data/Bodies.hs b/library/Octane/Data/Bodies.hs
deleted file mode 100644
--- a/library/Octane/Data/Bodies.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module Octane.Data.Bodies (bodies) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between body IDs and their names.
-bodies :: Bimap.Bimap Int StrictText.Text
-bodies =
-    [ ("Armadillo", 625) -- GearsCar
-    , ("Backfire", 21)
-    , ("Batmobile", 803) -- Darkcar
-    , ("Breakout", 22) -- Force
-    , ("DeLorean Time Machine", 597) -- GreyCar
-    , ("Dominus", 403) -- MuscleCar
-    , ("Gizmo", 26) -- Spark
-    , ("Grog", 607) -- WastelandTruck
-    , ("Hogsticker", 723) -- Warthog
-    , ("Hotshot", 29) -- Torment
-    , ("Merc", 30) -- Vanquish
-    , ("Octane", 23)
-    , ("Paladin", 24) -- Orion
-    , ("Ripper", 600) -- Interceptor
-    , ("Road Hog", 25) -- Rhino
-    , ("Scarab", 404)
-    , ("Sweet Tooth", 27)
-    , ("Takumi", 402) -- Import
-    , ("Venom", 31)
-    , ("X-Devil", 28) -- Torch
-    , ("Zippy", 523)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Bots.hs b/library/Octane/Data/Bots.hs
deleted file mode 100644
--- a/library/Octane/Data/Bots.hs
+++ /dev/null
@@ -1,66 +0,0 @@
-module Octane.Data.Bots (bots) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between bot IDs and their names.
-bots :: Bimap.Bimap Int StrictText.Text
-bots =
-    [ ("Astro", 70)
-    , ("Bane", 71)
-    , ("Beast", 72)
-    , ("Blade", 73)
-    , ("C-Block", 74)
-    , ("Captain", 75)
-    , ("Caveman", 76)
-    , ("Cougar", 77)
-    , ("Devil", 78)
-    , ("Diesel", 79)
-    , ("Dotty", 80)
-    , ("Dude", 81)
-    , ("Duke", 82)
-    , ("Dynamite", 83)
-    , ("Flame", 84)
-    , ("Flash", 85)
-    , ("Fossil", 86)
-    , ("Fury", 87)
-    , ("Hawk", 88)
-    , ("Hunter", 89)
-    , ("Imp", 90)
-    , ("Jet", 91)
-    , ("JM", 92)
-    , ("Kidd", 93)
-    , ("Laser", 94)
-    , ("Lightning", 95)
-    , ("Lucky", 96)
-    , ("Middy", 97)
-    , ("Mountain", 98)
-    , ("Nitro", 99)
-    , ("Outlaw", 100)
-    , ("Phoenix", 101)
-    , ("Raja", 102)
-    , ("Razor", 103)
-    , ("Retro", 104)
-    , ("Roundhouse", 105)
-    , ("Sabre", 106)
-    , ("Samson", 107)
-    , ("Savage", 108)
-    , ("Shadow", 109)
-    , ("Shield", 110)
-    , ("Siren", 111)
-    , ("Sky", 112)
-    , ("Squall", 113)
-    , ("Tank", 114)
-    , ("Thunder", 115)
-    , ("Titan", 116)
-    , ("Turbo", 117)
-    , ("Tusk", 118)
-    , ("Viper", 119)
-    , ("Warrior", 120)
-    , ("Wolf", 121)
-    , ("Yuri", 122)
-    , ("Zap", 123)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Classes.hs b/library/Octane/Data/Classes.hs
deleted file mode 100644
--- a/library/Octane/Data/Classes.hs
+++ /dev/null
@@ -1,179 +0,0 @@
-module Octane.Data.Classes
-    ( objectToClass
-    , locationClasses
-    , rotationClasses
-    ) where
-
-import Data.Function ((&))
-import Data.Monoid ((<>))
-
-import qualified Data.Map.Strict as Map
-import qualified Data.Set as Set
-import qualified Data.Text as StrictText
-
-
--- | A map from object names to their class names. Note that any trailing
--- numbers have been stripped from the object names. So
--- @Archetypes.Teams.Team0@ is in this map as @Archetypes.Teams.Team@.
-objectToClass :: Map.Map StrictText.Text StrictText.Text
-objectToClass = Map.foldrWithKey
-    (\ klass objects m -> objects
-        & Set.map (\ object -> (object, klass))
-        & Set.toList
-        & Map.fromList
-        & Map.union m)
-    Map.empty
-    classToObjects
-
-
--- | A set of classes that have an initial location vector.
-locationClasses :: Set.Set StrictText.Text
-locationClasses =
-    [ "TAGame.Ball_TA"
-    , "TAGame.CameraSettingsActor_TA"
-    , "TAGame.CarComponent_Boost_TA"
-    , "TAGame.CarComponent_Dodge_TA"
-    , "TAGame.CarComponent_DoubleJump_TA"
-    , "TAGame.CarComponent_FlipCar_TA"
-    , "TAGame.CarComponent_Jump_TA"
-    , "TAGame.Car_Season_TA"
-    , "TAGame.Car_TA"
-    , "TAGame.GRI_TA"
-    , "TAGame.GameEvent_Season_TA"
-    , "TAGame.GameEvent_SoccarPrivate_TA"
-    , "TAGame.GameEvent_SoccarSplitscreen_TA"
-    , "TAGame.GameEvent_Soccar_TA"
-    , "TAGame.PRI_TA"
-    , "TAGame.Team_Soccar_TA"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of classes that have an initial rotation vector.
-rotationClasses :: Set.Set StrictText.Text
-rotationClasses =
-    [ "TAGame.Ball_TA"
-    , "TAGame.Car_Season_TA"
-    , "TAGame.Car_TA"
-    ] & map StrictText.pack & Set.fromList
-
-
-classToObjects :: Map.Map StrictText.Text (Set.Set StrictText.Text)
-classToObjects = let
-    normal =
-        [ ( "TAGame.Ball_TA",
-            [ "Archetypes.Ball.Ball_Default"
-            , "Archetypes.Ball.Ball_Basketball"
-            , "Archetypes.Ball.Ball_Puck"
-            , "Archetypes.Ball.CubeBall"
-            ])
-        , ( "TAGame.CameraSettingsActor_TA",
-            [ "TAGame.CameraSettingsActor_TA:PRI"
-            , "TAGame.Default__CameraSettingsActor_TA"
-            ])
-        , ( "TAGame.CarComponent_Boost_TA",
-            [ "Archetypes.CarComponents.CarComponent_Boost"
-            ])
-        , ( "TAGame.CarComponent_Dodge_TA",
-            [ "Archetypes.CarComponents.CarComponent_Dodge"
-            ])
-        , ( "TAGame.CarComponent_DoubleJump_TA",
-            [ "Archetypes.CarComponents.CarComponent_DoubleJump"
-            ])
-        , ( "TAGame.CarComponent_FlipCar_TA",
-            [ "Archetypes.CarComponents.CarComponent_FlipCar"
-            ])
-        , ( "TAGame.CarComponent_Jump_TA",
-            [ "Archetypes.CarComponents.CarComponent_Jump"
-            ])
-        , ( "TAGame.Car_TA",
-            [ "Archetypes.Car.Car_Default"
-            , "Archetypes.GameEvent.GameEvent_Season:CarArchetype"
-            ])
-        , ( "TAGame.GameEvent_Season_TA",
-            [ "Archetypes.GameEvent.GameEvent_Season"
-            ])
-        , ( "TAGame.GameEvent_SoccarPrivate_TA",
-            [ "Archetypes.GameEvent.GameEvent_BasketballPrivate"
-            , "Archetypes.GameEvent.GameEvent_HockeyPrivate"
-            , "Archetypes.GameEvent.GameEvent_SoccarPrivate"
-            ])
-        , ( "TAGame.GameEvent_SoccarSplitscreen_TA",
-            [ "Archetypes.GameEvent.GameEvent_BasketballSplitscreen"
-            , "Archetypes.GameEvent.GameEvent_HockeySplitscreen"
-            , "Archetypes.GameEvent.GameEvent_SoccarSplitscreen"
-            ])
-        , ( "TAGame.GameEvent_Soccar_TA",
-            [ "Archetypes.GameEvent.GameEvent_Soccar"
-            , "Archetypes.GameEvent.GameEvent_Basketball"
-            ])
-        , ( "TAGame.GRI_TA",
-            [ "GameInfo_Basketball.GameInfo.GameInfo_Basketball:GameReplicationInfoArchetype"
-            , "Gameinfo_Hockey.GameInfo.Gameinfo_Hockey:GameReplicationInfoArchetype"
-            , "GameInfo_Season.GameInfo.GameInfo_Season:GameReplicationInfoArchetype"
-            , "GameInfo_Soccar.GameInfo.GameInfo_Soccar:GameReplicationInfoArchetype"
-            ])
-        , ( "TAGame.PRI_TA",
-            [ "TAGame.Default__PRI_TA"
-            ])
-        , ( "TAGame.Team_Soccar_TA",
-            [ "Archetypes.Teams.Team"
-            ])
-        , ( "TAGame.VoteActor_TA",
-            [ "TAGame.Default__VoteActor_TA"
-            ])
-        ] & map (\ (klass, objects) ->
-            ( StrictText.pack klass
-            , objects & map StrictText.pack & Set.fromList
-            ))
-    special =
-        [ ("TAGame.CrowdActor_TA", ".TheWorld:PersistentLevel.CrowdActor_TA_")
-        , ("TAGame.CrowdManager_TA", ".TheWorld:PersistentLevel.CrowdManager_TA_")
-        , ("TAGame.InMapScoreboard_TA", ".TheWorld:PersistentLevel.InMapScoreboard_TA_")
-        , ("TAGame.VehiclePickup_Boost_TA", ".TheWorld:PersistentLevel.VehiclePickup_Boost_TA_")
-        ] & map (\ (klass, suffix) ->
-            ( StrictText.pack klass
-            , levels & Set.map (\ level -> level <> StrictText.pack suffix)
-            ))
-    in Map.fromList (normal ++ special)
-
-
-levels :: Set.Set StrictText.Text
-levels =
-    [ "EuroStadium_Rainy_P"
-    , "HoopsStadium_P"
-    , "Neotokyo_p"
-    , "Park_Night_P"
-    , "Park_Rainy_P"
-    , "Stadium_p"
-    , "TrainStation_Night_P"
-    , "TrainStation_P"
-    , "Trainstation_Night_P"
-    , "UtopiaStadium_Dusk_P"
-    , "UtopiaStadium_Dusk_p"
-    , "UtopiaStadium_P"
-    , "Utopiastadium_p"
-    , "Wasteland_P"
-    , "Wasteland_p"
-    , "eurostad_oob_audio_map"
-    , "eurostadium_p"
-    , "eurostadium_rainy_audio"
-    , "hoopsstadium_sfx"
-    , "labs_circlepillars_p"
-    , "labs_cosmic_p"
-    , "labs_doublegoal_p"
-    , "labs_sfx"
-    , "labs_underpass_p"
-    , "labs_utopia_p"
-    , "neotokyo_sfx"
-    , "park_night_sfx"
-    , "park_p"
-    , "park_rainy_sfx"
-    , "park_sfx"
-    , "stadium_oob_audio_map"
-    , "stadium_p"
-    , "stadium_winter_p"
-    , "trainstation_p"
-    , "utopiastadium_p"
-    , "utopiastadium_sfx"
-    , "wasteland_sfx"
-    ] & map StrictText.pack & Set.fromList
diff --git a/library/Octane/Data/Decals.hs b/library/Octane/Data/Decals.hs
deleted file mode 100644
--- a/library/Octane/Data/Decals.hs
+++ /dev/null
@@ -1,220 +0,0 @@
-module Octane.Data.Decals (decals) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between decal IDs and their names.
-decals :: Bimap.Bimap Int StrictText.Text
-decals =
-    [ ("Backfire", backfireDecals)
-    , ("Breakout", breakoutDecals)
-    , ("Dominus", dominusDecals)
-    , ("Gizmo", gizmoDecals)
-    , ("Grog", grogDecals)
-    , ("Hotshot", hotshotDecals)
-    , ("Merc", mercDecals)
-    , ("Octane", octaneDecals)
-    , ("Paladin", paladinDecals)
-    , ("Ripper", ripperDecals)
-    , ("Road Hog", roadHogDecals)
-    , ("Scarab", scarabDecals)
-    , ("Takumi", takumiDecals)
-    , ("Venom", venomDecals)
-    , ("X-Devil", xDevilDecals)
-    , ("Zippy", zippyDecals)
-    ]
-        & map (\ (body, values) -> values &
-            map (\ (v, k) -> (k, v ++ " [" ++ body ++ "]")))
-        & concat
-        & map (\ (k, v) -> (k, StrictText.pack v))
-        & Bimap.fromList
-        & Bimap.insert 0 (StrictText.pack "None")
-
-
-backfireDecals :: [(String, Int)]
-backfireDecals =
-    [ ("Flames", 288)
-    , ("Lightning", 289)
-    , ("Skulls", 290)
-    , ("Stars", 291)
-    , ("Stripes", 292)
-    , ("Tech", 293)
-    , ("Wings", 294)
-    ]
-
-
-breakoutDecals :: [(String, Int)]
-breakoutDecals =
-    [ ("Flames", 295)
-    , ("Lightning", 296)
-    , ("Skulls", 297)
-    , ("Stars", 298)
-    , ("Stripes", 299)
-    , ("Tech", 300)
-    , ("Wings", 301)
-    ]
-
-
-dominusDecals :: [(String, Int)]
-dominusDecals =
-    [ ("Flames", 504)
-    , ("Scorpions", 506) -- Skorpion
-    , ("Skulls", 507)
-    , ("Stripes", 508)
-    , ("Tats", 509) -- Tribal
-    , ("Wings", 510)
-    ]
-
-
-gizmoDecals :: [(String, Int)]
-gizmoDecals =
-    [ ("Flames", 323)
-    , ("Lightning", 324)
-    , ("Skulls", 325)
-    , ("Stars", 326)
-    , ("Stripes", 327)
-    , ("Tech", 328)
-    , ("Wings", 329)
-    ]
-
-
-grogDecals :: [(String, Int)]
-grogDecals =
-    [ ("Bomber", 674)
-    , ("Cyclops", 694)
-    , ("Lepus", 675) -- Bunny
-    , ("Stripes", 687)
-    , ("Tagged", 695)
-    , ("Tribal", 696)
-    ]
-
-
-hotshotDecals :: [(String, Int)]
-hotshotDecals =
-    [ ("Flames", 337)
-    , ("Lightning", 338)
-    , ("Skulls", 339)
-    , ("Stars", 340)
-    , ("Stripes", 341)
-    , ("Tech", 342)
-    , ("Wings", 343)
-    ]
-
-
-mercDecals :: [(String, Int)]
-mercDecals =
-    [ ("Dots", 344)
-    , ("Flames", 345)
-    , ("Lightning", 346)
-    , ("Skulls", 347)
-    , ("Stars", 348)
-    , ("Stripes", 349)
-    , ("Wings", 350)
-    ]
-
-
-octaneDecals :: [(String, Int)]
-octaneDecals =
-    [ ("Flames", 302)
-    , ("Lightning", 303)
-    , ("Skulls", 304)
-    , ("Stars", 305)
-    , ("Stripes", 306)
-    , ("Tech", 307)
-    , ("Wings", 308)
-    ]
-
-
-paladinDecals :: [(String, Int)]
-paladinDecals =
-    [ ("Flames", 309)
-    , ("Lightning", 310)
-    , ("Skulls", 311)
-    , ("Stars", 312)
-    , ("Stripes", 313)
-    , ("Tech", 314)
-    , ("Wings", 315)
-    ]
-
-
-ripperDecals :: [(String, Int)]
-ripperDecals =
-    [ ("Bomber", 701) -- Sandgirl
-    , ("Flames", 669)
-    , ("Ockie", 700) -- Getkraken
-    , ("Shot Fox", 670) -- Kitsune
-    , ("Spikes", 671)
-    , ("Tribal", 672)
-    ]
-
-
-roadHogDecals :: [(String, Int)]
-roadHogDecals =
-    [ ("Flames", 316)
-    , ("Lightning", 317)
-    , ("Skulls", 318)
-    , ("Stars", 319)
-    , ("Stripes", 320)
-    , ("Tech", 321) -- TechSplat
-    , ("Wings", 322)
-    ]
-
-
-scarabDecals :: [(String, Int)]
-scarabDecals =
-    [ ("Bomani", 585) -- Clouds
-    , ("Derby Girl", 592) -- RollerDerby
-    , ("Flames", 591)
-    , ("Hearts", 586)
-    , ("Tiger", 587)
-    , ("Tribal", 588)
-    ]
-
-
-takumiDecals :: [(String, Int)]
-takumiDecals =
-    [ ("Chaser", 503) -- Wisp
-    , ("Copycat", 497) -- Gato
-    , ("Crazy-8", 502) -- Technologic
-    , ("Gaki", 499) -- Ohai
-    , ("Reiko", 498) -- Nitrous
-    , ("Stripes", 501)
-    ]
-
-
-venomDecals :: [(String, Int)]
-venomDecals =
-    [ ("Dots", 351)
-    , ("Flames", 352)
-    , ("Lightning", 353)
-    , ("Skulls", 354)
-    , ("Stripes", 355)
-    , ("Tagged", 356)
-    , ("Wings", 357)
-    ]
-
-
-xDevilDecals :: [(String, Int)]
-xDevilDecals =
-    [ ("Flames", 330)
-    , ("Lightning", 331)
-    , ("Skulls", 332)
-    , ("Stars", 333)
-    , ("Stripes", 334)
-    , ("Tech", 335)
-    , ("Wings", 336)
-    ]
-
-
-zippyDecals :: [(String, Int)]
-zippyDecals =
-    [ ("Caboodle", 589) -- HeckTec
-    , ("Callous", 590) -- MeanMachine
-    , ("Flames", 583)
-    , ("Hearts", 581)
-    , ("Leopard", 584)
-    , ("Tiger", 582)
-    ]
diff --git a/library/Octane/Data/Finishes.hs b/library/Octane/Data/Finishes.hs
deleted file mode 100644
--- a/library/Octane/Data/Finishes.hs
+++ /dev/null
@@ -1,28 +0,0 @@
-module Octane.Data.Finishes (finishes) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between paint finish IDs and their names.
-finishes :: Bimap.Bimap Int StrictText.Text
-finishes =
-    [ ("Brushed Metal", 266)
-    , ("Camo", 541)
-    , ("Carbon Fiber", 272) -- GlossyCarbonFiber
-    , ("Corroded Metal", 268) -- Corroded
-    , ("DeLorean Time Machine", 623) -- GreyCar
-    , ("Glossy", 270) -- Default
-    , ("Matte", 273)
-    , ("Metallic", 274)
-    , ("Metallic Pearl", 275)
-    , ("Pearlescent", 276)
-    , ("Semigloss", 277) -- Plastic
-    , ("Sun-Damaged", 542) -- Cracked
-    , ("Toon Glossy", 279)
-    , ("Toon Matte", 281)
-    , ("Toon Wood", 280)
-    , ("Wood", 283)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/GameModes.hs b/library/Octane/Data/GameModes.hs
deleted file mode 100644
--- a/library/Octane/Data/GameModes.hs
+++ /dev/null
@@ -1,15 +0,0 @@
-module Octane.Data.GameModes (gameModes) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between game mode IDs and their names.
-gameModes :: Bimap.Bimap Int StrictText.Text
-gameModes =
-    [ ("Soccar", 0)
-    , ("Hockey", 1)
-    , ("Hoops", 2)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Logos.hs b/library/Octane/Data/Logos.hs
deleted file mode 100644
--- a/library/Octane/Data/Logos.hs
+++ /dev/null
@@ -1,33 +0,0 @@
-module Octane.Data.Logos (logos) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between logo IDs and their names.
-logos :: Bimap.Bimap Int StrictText.Text
-logos =
-    [ ("Barracudas", 244)
-    , ("Blacklight", 245)
-    , ("Bombers", 246)
-    , ("Cyclones", 247)
-    , ("Destroyers", 248)
-    , ("Dragons", 249)
-    , ("Express", 250)
-    , ("Guardians", 251)
-    , ("Knights", 252)
-    , ("Kodiaks", 253)
-    , ("Mammoths", 254)
-    , ("Monarchs", 255)
-    , ("Phantoms", 256)
-    , ("Pharaohs", 257)
-    , ("Pioneers", 258)
-    , ("Ravagers", 259)
-    , ("Rebels", 260)
-    , ("Rovers", 261)
-    , ("Scorpions", 262)
-    , ("Skyhawks", 263)
-    , ("Wolves", 264)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Properties.hs b/library/Octane/Data/Properties.hs
deleted file mode 100644
--- a/library/Octane/Data/Properties.hs
+++ /dev/null
@@ -1,294 +0,0 @@
-module Octane.Data.Properties
-    ( booleanProperties
-    , byteProperties
-    , camSettingsProperties
-    , demolishProperties
-    , enumProperties
-    , explosionProperties
-    , flaggedIntProperties
-    , floatProperties
-    , gameModeProperties
-    , intProperties
-    , loadoutOnlineProperties
-    , loadoutProperties
-    , locationProperties
-    , musicStingerProperties
-    , pickupProperties
-    , privateMatchSettingsProperties
-    , qWordProperties
-    , relativeRotationProperties
-    , reservationProperties
-    , rigidBodyStateProperties
-    , stringProperties
-    , teamPaintProperties
-    , uniqueIdProperties
-    ) where
-
-import Data.Function ((&))
-
-import qualified Data.Set as Set
-import qualified Data.Text as StrictText
-
-
--- | A set of properties that are booleans.
-booleanProperties :: Set.Set StrictText.Text
-booleanProperties =
-    [ "Engine.Actor:bBlockActors"
-    , "Engine.Actor:bCollideActors"
-    , "Engine.Actor:bCollideWorld"
-    , "Engine.Actor:bHardAttach"
-    , "Engine.Actor:bHidden"
-    , "Engine.Actor:bNetOwner"
-    , "Engine.Actor:bProjTarget"
-    , "Engine.Actor:bTearOff"
-    , "Engine.GameReplicationInfo:bMatchIsOver"
-    , "Engine.Pawn:bCanSwatTurn"
-    , "Engine.Pawn:bRootMotionFromInterpCurve"
-    , "Engine.Pawn:bSimulateGravity"
-    , "Engine.PlayerReplicationInfo:bAdmin"
-    , "Engine.PlayerReplicationInfo:bBot"
-    , "Engine.PlayerReplicationInfo:bFromPreviousLevel"
-    , "Engine.PlayerReplicationInfo:bIsSpectator"
-    , "Engine.PlayerReplicationInfo:bOnlySpectator"
-    , "Engine.PlayerReplicationInfo:bOutOfLives"
-    , "Engine.PlayerReplicationInfo:bReadyToPlay"
-    , "Engine.PlayerReplicationInfo:bWaitingPlayer"
-    , "ProjectX.GRI_X:bGameStarted"
-    , "TAGame.CameraSettingsActor_TA:bUsingBehindView"
-    , "TAGame.CameraSettingsActor_TA:bUsingSecondaryCamera"
-    , "TAGame.CarComponent_Boost_TA:bNoBoost"
-    , "TAGame.CarComponent_Boost_TA:bUnlimitedBoost"
-    , "TAGame.CarComponent_FlipCar_TA:bFlipRight"
-    , "TAGame.GameEvent_Soccar_TA:bBallHasBeenHit"
-    , "TAGame.GameEvent_Soccar_TA:bOverTime"
-    , "TAGame.GameEvent_TA:bHasLeaveMatchPenalty"
-    , "TAGame.GameEvent_Team_TA:bDisableMutingOtherTeam"
-    , "TAGame.PRI_TA:bIsInSplitScreen"
-    , "TAGame.PRI_TA:bMatchMVP"
-    , "TAGame.PRI_TA:bOnlineLoadoutSet"
-    , "TAGame.PRI_TA:bReady"
-    , "TAGame.PRI_TA:bUsingBehindView"
-    , "TAGame.PRI_TA:bUsingFreecam"
-    , "TAGame.PRI_TA:bUsingSecondaryCamera"
-    , "TAGame.PRI_TA:bVoteToForfeitDisabled"
-    , "TAGame.RBActor_TA:bFrozen"
-    , "TAGame.RBActor_TA:bReplayActor"
-    , "TAGame.Vehicle_TA:bDriving"
-    , "TAGame.Vehicle_TA:bReplicatedHandbrake"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are bytes.
-byteProperties :: Set.Set StrictText.Text
-byteProperties =
-    [ "Engine.PlayerReplicationInfo:Ping"
-    , "TAGame.Ball_TA:HitTeamNum"
-    , "TAGame.CameraSettingsActor_TA:CameraPitch"
-    , "TAGame.CameraSettingsActor_TA:CameraYaw"
-    , "TAGame.CarComponent_Boost_TA:ReplicatedBoostAmount"
-    , "TAGame.CarComponent_TA:ReplicatedActive"
-    , "TAGame.GameEvent_Soccar_TA:ReplicatedScoredOnTeam"
-    , "TAGame.GameEvent_TA:ReplicatedStateIndex"
-    , "TAGame.PRI_TA:CameraPitch"
-    , "TAGame.PRI_TA:CameraYaw"
-    , "TAGame.Vehicle_TA:ReplicatedSteer"
-    , "TAGame.Vehicle_TA:ReplicatedThrottle"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are camera settings.
-camSettingsProperties :: Set.Set StrictText.Text
-camSettingsProperties =
-    [ "TAGame.CameraSettingsActor_TA:ProfileSettings"
-    , "TAGame.PRI_TA:CameraSettings"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are demolitions.
-demolishProperties :: Set.Set StrictText.Text
-demolishProperties =
-    [ "TAGame.Car_TA:ReplicatedDemolish"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are enumerations.
-enumProperties :: Set.Set StrictText.Text
-enumProperties =
-    [ "Engine.Actor:Role"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are explosions.
-explosionProperties :: Set.Set StrictText.Text
-explosionProperties =
-    [ "TAGame.Ball_TA:ReplicatedExplosionData"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are flagged integers.
-flaggedIntProperties :: Set.Set StrictText.Text
-flaggedIntProperties =
-    [ "Engine.Actor:Owner"
-    , "Engine.Actor:ReplicatedCollisionType"
-    , "Engine.GameReplicationInfo:GameClass"
-    , "Engine.Pawn:PlayerReplicationInfo"
-    , "Engine.PlayerReplicationInfo:Team"
-    , "Engine.TeamInfo:TeamIndex"
-    , "TAGame.Ball_TA:GameEvent"
-    , "TAGame.CameraSettingsActor_TA:PRI"
-    , "TAGame.CarComponent_TA:Vehicle"
-    , "TAGame.CrowdActor_TA:GameEvent"
-    , "TAGame.CrowdActor_TA:ReplicatedOneShotSound"
-    , "TAGame.CrowdManager_TA:GameEvent"
-    , "TAGame.CrowdManager_TA:ReplicatedGlobalOneShotSound"
-    , "TAGame.PRI_TA:PersistentCamera"
-    , "TAGame.PRI_TA:ReplicatedGameEvent"
-    , "TAGame.Team_TA:GameEvent"
-    , "TAGame.Team_TA:LogoData"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are floats.
-floatProperties :: Set.Set StrictText.Text
-floatProperties =
-    [ "Engine.Actor:DrawScale"
-    , "TAGame.Ball_TA:ReplicatedAddedCarBounceScale"
-    , "TAGame.Ball_TA:ReplicatedBallMaxLinearSpeedScale"
-    , "TAGame.Ball_TA:ReplicatedBallScale"
-    , "TAGame.Ball_TA:ReplicatedWorldBounceScale"
-    , "TAGame.CarComponent_Boost_TA:BoostModifier"
-    , "TAGame.CarComponent_Boost_TA:RechargeDelay"
-    , "TAGame.CarComponent_Boost_TA:RechargeRate"
-    , "TAGame.CarComponent_FlipCar_TA:FlipCarTime"
-    , "TAGame.CrowdActor_TA:ModifiedNoise"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are game modes.
-gameModeProperties :: Set.Set StrictText.Text
-gameModeProperties =
-    [ "TAGame.GameEvent_TA:GameMode"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are integers.
-intProperties :: Set.Set StrictText.Text
-intProperties =
-    [ "Engine.PlayerReplicationInfo:PlayerID"
-    , "Engine.PlayerReplicationInfo:Score"
-    , "Engine.TeamInfo:Score"
-    , "ProjectX.GRI_X:ReplicatedGameMutatorIndex"
-    , "ProjectX.GRI_X:ReplicatedGamePlaylist"
-    , "TAGame.CrowdActor_TA:ReplicatedCountDownNumber"
-    , "TAGame.GameEvent_Soccar_TA:RoundNum"
-    , "TAGame.GameEvent_Soccar_TA:SecondsRemaining"
-    , "TAGame.GameEvent_TA:BotSkill"
-    , "TAGame.GameEvent_TA:ReplicatedGameStateTimeRemaining"
-    , "TAGame.GameEvent_TA:ReplicatedStateName"
-    , "TAGame.GameEvent_Team_TA:MaxTeamSize"
-    , "TAGame.PRI_TA:MatchAssists"
-    , "TAGame.PRI_TA:MatchGoals"
-    , "TAGame.PRI_TA:MatchSaves"
-    , "TAGame.PRI_TA:MatchScore"
-    , "TAGame.PRI_TA:MatchShots"
-    , "TAGame.PRI_TA:Title"
-    , "TAGame.PRI_TA:TotalXP"
-    , "TAGame.Team_Soccar_TA:GameScore"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are online loadouts.
-loadoutOnlineProperties :: Set.Set StrictText.Text
-loadoutOnlineProperties =
-    [ "TAGame.PRI_TA:ClientLoadoutOnline"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are loadouts.
-loadoutProperties :: Set.Set StrictText.Text
-loadoutProperties =
-    [ "TAGame.PRI_TA:ClientLoadout"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are locations.
-locationProperties :: Set.Set StrictText.Text
-locationProperties =
-    [ "Engine.Actor:RelativeLocation"
-    , "TAGame.CarComponent_Dodge_TA:DodgeTorque"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are music stingers.
-musicStingerProperties :: Set.Set StrictText.Text
-musicStingerProperties =
-    [ "TAGame.GameEvent_Soccar_TA:ReplicatedMusicStinger"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are pickups.
-pickupProperties :: Set.Set StrictText.Text
-pickupProperties =
-    [ "TAGame.VehiclePickup_TA:ReplicatedPickupData"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are private match settings.
-privateMatchSettingsProperties :: Set.Set StrictText.Text
-privateMatchSettingsProperties =
-    [ "TAGame.GameEvent_SoccarPrivate_TA:MatchSettings"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are qwords.
-qWordProperties :: Set.Set StrictText.Text
-qWordProperties =
-    [ "ProjectX.GRI_X:GameServerID"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are relation rotations.
-relativeRotationProperties :: Set.Set StrictText.Text
-relativeRotationProperties =
-    [ "Engine.Actor:RelativeRotation"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are reservations.
-reservationProperties :: Set.Set StrictText.Text
-reservationProperties =
-    [ "ProjectX.GRI_X:Reservations"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are rigid body states.
-rigidBodyStateProperties :: Set.Set StrictText.Text
-rigidBodyStateProperties =
-    [ "TAGame.RBActor_TA:ReplicatedRBState"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are strings.
-stringProperties :: Set.Set StrictText.Text
-stringProperties =
-    [ "Engine.GameReplicationInfo:ServerName"
-    , "Engine.PlayerReplicationInfo:PlayerName"
-    , "Engine.PlayerReplicationInfo:RemoteUserData"
-    , "TAGame.GRI_TA:NewDedicatedServerIP"
-    , "TAGame.Team_TA:CustomTeamName"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are team paints.
-teamPaintProperties :: Set.Set StrictText.Text
-teamPaintProperties =
-    [ "TAGame.Car_TA:TeamPaint"
-    ] & map StrictText.pack & Set.fromList
-
-
--- | A set of properties that are unique IDs.
-uniqueIdProperties :: Set.Set StrictText.Text
-uniqueIdProperties =
-    [ "Engine.PlayerReplicationInfo:UniqueId"
-    -- , "TAGame.PRI_TA:PartyLeader"
-    ] & map StrictText.pack & Set.fromList
diff --git a/library/Octane/Data/RocketTrails.hs b/library/Octane/Data/RocketTrails.hs
deleted file mode 100644
--- a/library/Octane/Data/RocketTrails.hs
+++ /dev/null
@@ -1,63 +0,0 @@
-module Octane.Data.RocketTrails (rocketTrails) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between rocket trail IDs and their names.
-rocketTrails :: Bimap.Bimap Int StrictText.Text
-rocketTrails =
-    [ ("(Alpha Reward) Gold Rush", 32) -- AlphaReward
-    , ("Accelerato", 524) -- MusicalNotes
-    , ("Batmobile", 868) -- DarkCar
-    , ("Battle-Stars", 545)
-    , ("Bubbles", 33)
-    , ("Burnout", 387)
-    , ("Candy Corn", 578)
-    , ("Confetti", 34)
-    , ("Datastream", 35) -- Digital
-    , ("Flamethrower Blue", 37)
-    , ("Flamethrower Green", 38)
-    , ("Flamethrower Pink", 39)
-    , ("Flamethrower Purple", 40)
-    , ("Flamethrower Red", 41)
-    , ("Flamethrower", 36)
-    , ("Flowers", 42)
-    , ("Grass", 43)
-    , ("Hydro", 69) -- Water
-    , ("Ion Blue", 45) -- LightTrail
-    , ("Ion Green", 46)
-    , ("Ion Pink", 47)
-    , ("Ion Purple", 48)
-    , ("Ion Red", 49)
-    , ("Ion Yellow", 50)
-    , ("Money", 51)
-    , ("Nitrous", 384)
-    , ("Nuts & Bolts", 544)
-    , ("OutaTime", 626) -- GreyCar
-    , ("Plasma", 52)
-    , ("Portal - Conversion Gel", 651)
-    , ("Portal - Propulsion Gel", 652)
-    , ("Portal - Reuplsion Gel", 653)
-    , ("Rainbow", 59)
-    , ("Sacred", 44) -- HolyLight
-    , ("Sandstorm", 664)
-    , ("Slime", 60)
-    , ("Snowflakes", 61)
-    , ("Sparkles", 62)
-    , ("Standard Blue", 64)
-    , ("Standard Pink", 65)
-    , ("Standard Purple", 66)
-    , ("Standard Red", 63)
-    , ("Standard Yellow", 67)
-    , ("Sweet Tooth", 68)
-    , ("Thermal Blue", 54)
-    , ("Thermal Green", 55)
-    , ("Thermal Pink", 56)
-    , ("Thermal Purple", 57)
-    , ("Thermal Yellow", 58)
-    , ("Thermal", 53) -- Propulsion
-    , ("Xmas", 635) -- Presents
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Toppers.hs b/library/Octane/Data/Toppers.hs
deleted file mode 100644
--- a/library/Octane/Data/Toppers.hs
+++ /dev/null
@@ -1,78 +0,0 @@
-module Octane.Data.Toppers (toppers) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between topper IDs and their names.
-toppers :: Bimap.Bimap Int StrictText.Text
-toppers =
-    [ ("None", 0)
-    , ("(Alpha Reward) Gold Cap", 224) -- AlphaHat
-    , ("Antlers", 774)
-    , ("Beret", 775)
-    , ("Biker Cap", 776)
-    , ("Birthday Cake", 789)
-    , ("Blitzen", 640) -- ReindeerAntlers
-    , ("Bobby Helmet", 227) -- BPH
-    , ("Boombox", 521)
-    , ("Bowler", 745) -- GentlemanHat
-    , ("Brodie Helmet", 743) -- DoughboyHelmet
-    , ("Brünnhilde", 241) -- Viking_Helmet
-    , ("Captain's Hat", 741)
-    , ("Cavalier", 580) -- Tricorn
-    , ("Chainsaw", 795)
-    , ("Chef's Hat", 756)
-    , ("Cherry Top", 237) -- PoliceSiren
-    , ("Christmas Tree", 639) -- RegularChristmasTree
-    , ("Cockroach", 769)
-    , ("Cow Skull", 685)
-    , ("deadmau5", 796)
-    , ("Deerstalker", 792)
-    , ("Derby", 790)
-    , ("Devil Horns", 229)
-    , ("Fez", 230)
-    , ("Fire Helmet", 231)
-    , ("Foam Hat", 390) -- TourneyWinner
-    , ("Fruit Hat", 744)
-    , ("Graduation Cap", 778)
-    , ("Halo", 232)
-    , ("Hard Hat", 233)
-    , ("Homburg", 742) -- DetectiveHat
-    , ("Locomotive", 547) -- TrainConductor
-    , ("Mariachi Hat", 234)
-    , ("Mohawk", 638)
-    , ("Mouse Trap", 782) -- RatTrap
-    , ("Paper Boat", 770)
-    , ("Pirate's Hat", 235)
-    , ("Pixelated Shades", 494) -- DWI
-    , ("Pizza Topper", 236)
-    , ("Plunger", 772)
-    , ("Police Hat", 746)
-    , ("Portal - Cake", 661)
-    , ("Propellerhead", 225) -- Beanie
-    , ("Pumpkin", 546) -- CatOLantern
-    , ("Rasta", 771)
-    , ("Rhino Horns", 780)
-    , ("Royal Crown", 228)
-    , ("Sad Sapling", 641) -- SadChristmasTree
-    , ("Santa", 642)
-    , ("Season 1 - Bronze", 705)
-    , ("Season 1 - Gold", 706)
-    , ("Season 1 - Platinum", 707)
-    , ("Season 1 - Silver", 708)
-    , ("Shark Fin", 579)
-    , ("Shuriken", 773)
-    , ("Sombrero", 238)
-    , ("Taxi Topper", 239)
-    , ("Tiara", 495)
-    , ("Top Hat", 240)
-    , ("Traffic Cone", 747)
-    , ("Unicorn", 748) -- Unihorn
-    , ("Witch's Hat", 242)
-    , ("Wizard Hat", 243)
-    , ("Work Boot", 740)
-    , ("Worms W.M.D.", 1028)
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Data/Wheels.hs b/library/Octane/Data/Wheels.hs
deleted file mode 100644
--- a/library/Octane/Data/Wheels.hs
+++ /dev/null
@@ -1,51 +0,0 @@
-module Octane.Data.Wheels (wheels) where
-
-import Data.Function ((&))
-
-import qualified Data.Bimap as Bimap
-import qualified Data.Text as StrictText
-
-
--- | A one-to-one mapping between wheels IDs and their names.
-wheels :: Bimap.Bimap Int StrictText.Text
-wheels =
-    [ ("(Alpha Reward) Goldstone", 358) -- AlphaRim
-    , ("Alchemist", 374) -- Revolution
-    , ("Almas", 364) -- Crypt
-    , ("Armadillo", 618)
-    , ("Batmobile", 874) -- Darkcar
-    , ("Bender", 360) -- Ballistic
-    , ("Carriage", 549)
-    , ("Cog", 716) -- TODO: What is this called in game?
-    , ("Cristiano", 386) -- SoccerBall
-    , ("DeLorean Time Machine", 609) -- GreyCar
-    , ("Dieci", 363) -- Caliber
-    , ("Falco", 382) -- Vulcan
-    , ("Foreman", 372) -- OffRoad
-    , ("Grog", 613) -- WastelandTruck
-    , ("Hogsticker", 719) -- Warthog
-    , ("Invader", 380) -- Triad
-    , ("Lowrider", 369)
-    , ("Lucci", 361) -- Bling
-    , ("Mountaineer", 375) -- SnowTire
-    , ("Neptune", 370) -- Mob
-    , ("Octavian", 379) -- Tarantula
-    , ("OEM", 376) -- Star
-    , ("Rat Rod", 366) -- Dynamo
-    , ("Ripper", 690) -- Spiked
-    , ("Scarab", 540) -- Scarab
-    , ("Servergate", 519) -- SkullxBones
-    , ("Spinner", 388) -- Spinner
-    , ("Spyder", 371) -- Ninja
-    , ("Stallion", 365) -- DeepDish
-    , ("Stern", 368) -- Hydra
-    , ("Sunburt", 383) -- Wynd
-    , ("Sweet Tooth", 378)
-    , ("Tempest", 377) -- Storm
-    , ("Tomahawk", 362) -- Brink
-    , ("Trahere", 367) -- Forge
-    , ("Tunica", 359) -- Atlantis
-    , ("Veloce", 373) -- Pedigree
-    , ("Vortex", 381)
-    , ("Zippy", 518) -- SingleSpoke
-    ] & map (\ (v, k) -> (k, StrictText.pack v)) & Bimap.fromList
diff --git a/library/Octane/Type.hs b/library/Octane/Type.hs
--- a/library/Octane/Type.hs
+++ b/library/Octane/Type.hs
@@ -25,6 +25,7 @@
     , module Octane.Type.Stream
     , module Octane.Type.Text
     , module Octane.Type.Value
+    , module Octane.Type.Vector
     , module Octane.Type.Word16
     , module Octane.Type.Word32
     , module Octane.Type.Word64
@@ -57,6 +58,7 @@
 import Octane.Type.Stream
 import Octane.Type.Text
 import Octane.Type.Value
+import Octane.Type.Vector
 import Octane.Type.Word16
 import Octane.Type.Word32
 import Octane.Type.Word64
diff --git a/library/Octane/Type/Boolean.hs b/library/Octane/Type/Boolean.hs
--- a/library/Octane/Type/Boolean.hs
+++ b/library/Octane/Type/Boolean.hs
@@ -13,7 +13,11 @@
 import qualified Data.Binary.Bits.Put as BinaryBit
 import qualified GHC.Generics as Generics
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A boolean value.
 newtype Boolean = Boolean
     { unpack :: Bool
@@ -21,6 +25,12 @@
 
 -- | Stored in the last bit of a byte. Decoding will fail if the byte is
 -- anything other than @0b00000000@ or @0b00000001@.
+--
+-- >>> Binary.decode "\x01" :: Boolean
+-- Boolean {unpack = True}
+--
+-- >>> Binary.encode (Boolean True)
+-- "\SOH"
 instance Binary.Binary Boolean where
     get = do
         value <- Binary.getWord8
@@ -36,6 +46,12 @@
         & Binary.putWord8
 
 -- | Stored as a bit.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80" :: Boolean
+-- Boolean {unpack = True}
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (Boolean True)))
+-- "\128"
 instance BinaryBit.BinaryBit Boolean where
     getBits _ = do
         value <- BinaryBit.getBool
@@ -48,6 +64,9 @@
 instance DeepSeq.NFData Boolean where
 
 -- | Encoded directly as a JSON boolean.
+--
+-- >>> Aeson.encode (Boolean True)
+-- "true"
 instance Aeson.ToJSON Boolean where
     toJSON boolean = boolean
         & unpack
diff --git a/library/Octane/Type/CacheItem.hs b/library/Octane/Type/CacheItem.hs
--- a/library/Octane/Type/CacheItem.hs
+++ b/library/Octane/Type/CacheItem.hs
@@ -13,7 +13,10 @@
 import qualified Octane.Type.List as List
 import qualified Octane.Type.Word32 as Word32
 
+-- $setup
+-- >>> :set -XOverloadedLists
 
+
 -- | An item in the class net cache map.
 data CacheItem = CacheItem
     { classId :: Word32.Word32
@@ -27,6 +30,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Fields are stored one after the other in order.
+--
+-- >>> Binary.decode "\x01\x00\x00\x00\x02\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00" :: CacheItem
+-- CacheItem {classId = 0x00000001, parentCacheId = 0x00000002, cacheId = 0x00000003, properties = fromList []}
+--
+-- >>> Binary.encode (CacheItem 1 2 3 [])
+-- "\SOH\NUL\NUL\NUL\STX\NUL\NUL\NUL\ETX\NUL\NUL\NUL\NUL\NUL\NUL\NUL"
 instance Binary.Binary CacheItem where
     get = CacheItem
         <$> Binary.get
diff --git a/library/Octane/Type/CacheProperty.hs b/library/Octane/Type/CacheProperty.hs
--- a/library/Octane/Type/CacheProperty.hs
+++ b/library/Octane/Type/CacheProperty.hs
@@ -21,6 +21,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Fields are stored one after the other in order.
+--
+-- >>> Binary.decode "\x01\x00\x00\x00\x02\x00\x00\x00" :: CacheProperty
+-- CacheProperty {objectId = 0x00000001, streamId = 0x00000002}
+--
+-- >>> Binary.encode (CacheProperty 1 2)
+-- "\SOH\NUL\NUL\NUL\STX\NUL\NUL\NUL"
 instance Binary.Binary CacheProperty where
     get = CacheProperty
         <$> Binary.get
diff --git a/library/Octane/Type/ClassItem.hs b/library/Octane/Type/ClassItem.hs
--- a/library/Octane/Type/ClassItem.hs
+++ b/library/Octane/Type/ClassItem.hs
@@ -23,6 +23,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Fields are stored one after the other in order.
+--
+-- >>> Binary.decode "\x02\x00\x00\x00\x4b\x00\x01\x00\x00\x00" :: ClassItem
+-- ClassItem {name = "K", streamId = 0x00000001}
+--
+-- >>> Binary.encode (ClassItem "K" 1)
+-- "\STX\NUL\NUL\NULK\NUL\SOH\NUL\NUL\NUL"
 instance Binary.Binary ClassItem where
     get = ClassItem
         <$> Binary.get
diff --git a/library/Octane/Type/Dictionary.hs b/library/Octane/Type/Dictionary.hs
--- a/library/Octane/Type/Dictionary.hs
+++ b/library/Octane/Type/Dictionary.hs
@@ -15,7 +15,11 @@
 import qualified GHC.Generics as Generics
 import qualified Octane.Type.Text as Text
 
+-- $setup
+-- >>> import GHC.Exts
+-- >>> import Octane.Type.Word8
 
+
 -- | A mapping between text and arbitrary values.
 newtype Dictionary a = Dictionary
     { unpack :: (Map.Map Text.Text a)
@@ -23,6 +27,12 @@
 
 -- | Elements are stored with the key first, then the value. The dictionary
 -- ends when a key is @"None"@.
+--
+-- >>> Binary.decode "\x02\x00\x00\x00k\x00\x01\x05\x00\x00\x00None\x00" :: Dictionary Word8
+-- fromList [("k",0x01)]
+--
+-- >>> Binary.encode ([("k", 1)] :: Dictionary Word8)
+-- "\STX\NUL\NUL\NULk\NUL\SOH\ENQ\NUL\NUL\NULNone\NUL"
 instance (Binary.Binary a) => Binary.Binary (Dictionary a) where
     get = do
         element <- getElement
@@ -38,6 +48,9 @@
 
 -- | Allows creating 'Dictionary' values with 'Exts.fromList'. Also allows
 -- 'Dictionary' literals with the @OverloadedLists@ extension.
+--
+-- >>> [("one", 1)] :: Dictionary Int
+-- fromList [("one",1)]
 instance Exts.IsList (Dictionary a) where
     type Item (Dictionary a) = (Text.Text, a)
 
@@ -47,11 +60,17 @@
 
 instance (DeepSeq.NFData a) => DeepSeq.NFData (Dictionary a) where
 
--- | Shown as @fromList [("key", "value")]@.
+-- | Shown as @fromList [("key","value")]@.
+--
+-- >>> show ([("one", 1)] :: Dictionary Int)
+-- "fromList [(\"one\",1)]"
 instance (Show a) => Show (Dictionary a) where
     show dictionary = show (unpack dictionary)
 
 -- | Encoded directly as a JSON object.
+--
+-- >>> Aeson.encode ([("one", 1)] :: Dictionary Int)
+-- "{\"one\":1}"
 instance (Aeson.ToJSON a) => Aeson.ToJSON (Dictionary a) where
     toJSON dictionary = dictionary
         & unpack
diff --git a/library/Octane/Type/Float32.hs b/library/Octane/Type/Float32.hs
--- a/library/Octane/Type/Float32.hs
+++ b/library/Octane/Type/Float32.hs
@@ -19,13 +19,21 @@
 import qualified GHC.Generics as Generics
 import qualified Octane.Utility.Endian as Endian
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A 32-bit float.
 newtype Float32 = Float32
     { unpack :: Float
     } deriving (Eq, Fractional, Generics.Generic, Num, Ord)
 
--- | Stored little-endian.
+-- | >>> Binary.decode "\x9a\x99\x99\x3f" :: Float32
+-- 1.2
+--
+-- >>> Binary.encode (1.2 :: Float32)
+-- "\154\153\153?"
 instance Binary.Binary Float32 where
     get = do
         value <- IEEE754.getFloat32le
@@ -35,7 +43,11 @@
         & unpack
         & IEEE754.putFloat32le
 
--- | Stored little-endian with the bits in each byte reversed.
+-- | >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x59\x99\x99\xfc" :: Float32
+-- 1.2
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1.2 :: Float32)))
+-- "Y\153\153\252"
 instance BinaryBit.BinaryBit Float32 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 4
@@ -55,10 +67,16 @@
 instance DeepSeq.NFData Float32
 
 -- | Shown as @12.34@.
+--
+-- >>> show (1.2 :: Float32)
+-- "1.2"
 instance Show Float32 where
     show float32 = show (unpack float32)
 
 -- | Encoded directly as a JSON number.
+--
+-- Aeson.encode (1.2 :: Float32)
+-- "1.2"
 instance Aeson.ToJSON Float32 where
     toJSON float32 = float32
         & unpack
diff --git a/library/Octane/Type/Frame.hs b/library/Octane/Type/Frame.hs
--- a/library/Octane/Type/Frame.hs
+++ b/library/Octane/Type/Frame.hs
@@ -18,7 +18,6 @@
 import qualified Octane.Data as Data
 import qualified Octane.Type.Float32 as Float32
 import qualified Octane.Type.Initialization as Initialization
-import qualified Octane.Type.RemoteId as RemoteId
 import qualified Octane.Type.Replication as Replication
 import qualified Octane.Type.State as State
 import qualified Octane.Type.Value as Value
@@ -27,6 +26,9 @@
 
 
 -- | A frame in the network stream. This holds all the interesting game data.
+--
+-- This cannot be an instance of 'Data.Binary.Bits.BinaryBit' because it
+-- requires out-of-band information (the class property map) to decode.
 data Frame = Frame
     { number :: Word
     -- ^ This frame's number in the network stream. Starts at 0.
@@ -138,7 +140,7 @@
     Value.VGameMode _ -> "GameMode"
     Value.VInt _ -> "Int"
     Value.VLoadout _ _ _ _ _ _ _ _ _ -> "Loadout"
-    Value.VLoadoutOnline _ _ _ _ -> "OnlineLoadout"
+    Value.VLoadoutOnline _ -> "OnlineLoadout"
     Value.VLocation _ -> "Position"
     Value.VMusicStinger _ _ _ -> "MusicStinger"
     Value.VPickup _ _ _ -> "Pickup"
@@ -178,32 +180,32 @@
         [ ("Version", Aeson.toJSON version)
         , ("Body", Aeson.object
             [ ("Id", Aeson.toJSON body)
-            , ("Name", body & getBody & Aeson.toJSON)
+            , ("Name", body & getProduct & Aeson.toJSON)
             ])
         , ("Decal", Aeson.object
             [ ("Id", Aeson.toJSON decal)
-            , ("Name", decal & getDecal & Aeson.toJSON)
+            , ("Name", decal & getProduct & Aeson.toJSON)
             ])
         , ("Wheels", Aeson.object
             [ ("Id", Aeson.toJSON wheels)
-            , ("Name", wheels & getWheels & Aeson.toJSON)
+            , ("Name", wheels & getProduct & Aeson.toJSON)
             ])
         , ("RocketTrail", Aeson.object
             [ ("Id", Aeson.toJSON rocketTrail)
-            , ("Name", rocketTrail & getRocketTrail & Aeson.toJSON)
+            , ("Name", rocketTrail & getProduct & Aeson.toJSON)
             ])
         , ("Antenna", Aeson.object
             [ ("Id", Aeson.toJSON antenna)
-            , ("Name", antenna & getAntenna & Aeson.toJSON)
+            , ("Name", antenna & getProduct & Aeson.toJSON)
             ])
         , ("Topper", Aeson.object
             [ ("Id", Aeson.toJSON topper)
-            , ("Name", topper & getTopper & Aeson.toJSON)
+            , ("Name", topper & getProduct & Aeson.toJSON)
             ])
         , ("Unknown1", Aeson.toJSON x)
         , ("Unknown2", Aeson.toJSON y)
         ]
-    Value.VLoadoutOnline a b c d -> Aeson.toJSON (a, b, c, d)
+    Value.VLoadoutOnline a -> Aeson.toJSON a
     Value.VLocation x -> Aeson.toJSON x
     Value.VMusicStinger a b c -> Aeson.toJSON (a, b, c)
     Value.VPickup a b c -> Aeson.toJSON (a, b, c)
@@ -240,11 +242,11 @@
         , ("AccentColor", Aeson.toJSON color2)
         , ("PrimaryFinish", Aeson.object
             [ ("Id", Aeson.toJSON finish1)
-            , ("Name", finish1 & getFinish & Aeson.toJSON)
+            , ("Name", finish1 & getProduct & Aeson.toJSON)
             ])
         , ("AccentFinish", Aeson.object
             [ ("Id", Aeson.toJSON finish2)
-            , ("Name", finish2 & getFinish & Aeson.toJSON)
+            , ("Name", finish2 & getProduct & Aeson.toJSON)
             ])
         ]
     Value.VUniqueId systemId remoteId localId -> Aeson.object
@@ -254,11 +256,7 @@
             2 -> "PlayStation"
             4 -> "Xbox"
             _ -> Aeson.String ("Unknown system " <> StrictText.pack (show systemId)))
-        , ("Remote", case remoteId of
-            RemoteId.SplitscreenId x -> Aeson.toJSON x
-            RemoteId.SteamId x -> Aeson.toJSON x
-            RemoteId.PlayStationId x -> Aeson.toJSON x
-            RemoteId.XboxId x -> Aeson.toJSON x)
+        , ("Remote", Aeson.toJSON remoteId)
         , ("Local", Aeson.toJSON localId)
         ]
 
@@ -267,29 +265,5 @@
 getGameMode x = Bimap.lookup (Word8.fromWord8 x) Data.gameModes
 
 
-getBody :: Word32.Word32 -> Maybe StrictText.Text
-getBody x = Bimap.lookup (Word32.fromWord32 x) Data.bodies
-
-
-getDecal :: Word32.Word32 -> Maybe StrictText.Text
-getDecal x = Bimap.lookup (Word32.fromWord32 x) Data.decals
-
-
-getWheels :: Word32.Word32 -> Maybe StrictText.Text
-getWheels x = Bimap.lookup (Word32.fromWord32 x) Data.wheels
-
-
-getRocketTrail :: Word32.Word32 -> Maybe StrictText.Text
-getRocketTrail x = Bimap.lookup (Word32.fromWord32 x) Data.rocketTrails
-
-
-getAntenna :: Word32.Word32 -> Maybe StrictText.Text
-getAntenna x = Bimap.lookup (Word32.fromWord32 x) Data.antennas
-
-
-getTopper :: Word32.Word32 -> Maybe StrictText.Text
-getTopper x = Bimap.lookup (Word32.fromWord32 x) Data.toppers
-
-
-getFinish :: Word32.Word32 -> Maybe StrictText.Text
-getFinish x = Bimap.lookup (Word32.fromWord32 x) Data.finishes
+getProduct :: Word32.Word32 -> Maybe StrictText.Text
+getProduct x = Bimap.lookup (Word32.fromWord32 x) Data.products
diff --git a/library/Octane/Type/Initialization.hs b/library/Octane/Type/Initialization.hs
--- a/library/Octane/Type/Initialization.hs
+++ b/library/Octane/Type/Initialization.hs
@@ -11,6 +11,9 @@
 
 
 -- | Information about a new instance of a class.
+--
+-- This cannot be an instance of 'Data.Binary.Bits.BinaryBit' because it
+-- requires out-of-band information (the class name) to decode.
 data Initialization = Initialization
     { location :: Maybe (Vector.Vector Int)
     -- ^ The instance's initial position.
diff --git a/library/Octane/Type/Int32.hs b/library/Octane/Type/Int32.hs
--- a/library/Octane/Type/Int32.hs
+++ b/library/Octane/Type/Int32.hs
@@ -25,7 +25,11 @@
     { unpack :: Int.Int32
     } deriving (Eq, Generics.Generic, Num, Ord)
 
--- | Stored in little-endian byte order.
+-- | >>> Binary.decode "\x01\x00\x00\x00" :: Int32
+-- 1
+--
+-- >>> Binary.encode (1 :: Int32)
+-- "\SOH\NUL\NUL\NUL"
 instance Binary.Binary Int32 where
     get = do
         value <- Binary.getInt32le
@@ -35,7 +39,11 @@
         let value = unpack int32
         Binary.putInt32le value
 
--- | Store little-endian with the bits in each byte reversed.
+-- | >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80\x00\x00\x00" :: Int32
+-- 1
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1 :: Int32)))
+-- "\128\NUL\NUL\NUL"
 instance BinaryBit.BinaryBit Int32 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 4
@@ -55,10 +63,16 @@
 instance DeepSeq.NFData Int32 where
 
 -- | Shown as @1234@.
+--
+-- show (1 :: Int32)
+-- "1"
 instance Show Int32 where
     show int32 = show (unpack int32)
 
 -- | Encoded as a JSON number directly.
+--
+-- >>> Aeson.encode (1 :: Int32)
+-- "1"
 instance Aeson.ToJSON Int32 where
     toJSON int32 = int32
         & unpack
diff --git a/library/Octane/Type/Int8.hs b/library/Octane/Type/Int8.hs
--- a/library/Octane/Type/Int8.hs
+++ b/library/Octane/Type/Int8.hs
@@ -19,12 +19,21 @@
 import qualified GHC.Generics as Generics
 import qualified Octane.Utility.Endian as Endian
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A 8-bit signed integer.
 newtype Int8 = Int8
     { unpack :: Int.Int8
     } deriving (Eq, Generics.Generic, Num, Ord)
 
+-- | >>> Binary.decode "\x01" :: Int8
+-- 1
+--
+-- >>> Binary.encode (1 :: Int8)
+-- "\SOH"
 instance Binary.Binary Int8 where
     get = do
         value <- Binary.getInt8
@@ -35,6 +44,12 @@
         Binary.putInt8 value
 
 -- | Stored with the bits reversed.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80" :: Int8
+-- 1
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1 :: Int8)))
+-- "\128"
 instance BinaryBit.BinaryBit Int8 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 1
@@ -54,10 +69,16 @@
 instance DeepSeq.NFData Int8 where
 
 -- | Shown as @1234@.
+--
+-- show (1 :: Int8)
+-- "1"
 instance Show Int8 where
     show int8 = show (unpack int8)
 
 -- | Encoded directly as a JSON number.
+--
+-- >>> Aeson.encode (1 :: Int8)
+-- "1"
 instance Aeson.ToJSON Int8 where
     toJSON int8 = int8
         & unpack
diff --git a/library/Octane/Type/KeyFrame.hs b/library/Octane/Type/KeyFrame.hs
--- a/library/Octane/Type/KeyFrame.hs
+++ b/library/Octane/Type/KeyFrame.hs
@@ -24,6 +24,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Stored with the fields one after the other in order.
+--
+-- >>> Binary.decode "\x00\x00\x00\x00\x01\x00\x00\x00\x02\x00\x00\x00" :: KeyFrame
+-- KeyFrame {time = 0.0, frame = 0x00000001, position = 0x00000002}
+--
+-- >>> Binary.encode (KeyFrame { time = 0, frame = 1, position = 2 })
+-- "\NUL\NUL\NUL\NUL\SOH\NUL\NUL\NUL\STX\NUL\NUL\NUL"
 instance Binary.Binary KeyFrame where
     get = KeyFrame
         <$> Binary.get
diff --git a/library/Octane/Type/List.hs b/library/Octane/Type/List.hs
--- a/library/Octane/Type/List.hs
+++ b/library/Octane/Type/List.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE DeriveGeneric #-}
 {-# LANGUAGE DuplicateRecordFields #-}
+{-# LANGUAGE TypeFamilies #-}
 
 module Octane.Type.List (List(..)) where
 
@@ -9,16 +10,26 @@
 import qualified Control.Monad as Monad
 import qualified Data.Aeson as Aeson
 import qualified Data.Binary as Binary
+import qualified GHC.Exts as Exts
 import qualified GHC.Generics as Generics
 import qualified Octane.Type.Word32 as Word32
 
+-- $setup
+-- >>> import Octane.Type.Int8
 
--- | A list of valeus.
+
+-- | A list of values.
 newtype List a = List
     { unpack :: [a]
-    } deriving (Eq, Generics.Generic, Ord, Show)
+    } deriving (Eq, Generics.Generic, Ord)
 
 -- | Prefixed with the number of elements in the list.
+--
+-- >>> Binary.decode "\x01\x00\x00\x00\x02" :: List Int8
+-- fromList [2]
+--
+-- >>> Binary.encode ([2] :: List Int8)
+-- "\SOH\NUL\NUL\NUL\STX"
 instance (Binary.Binary a) => Binary.Binary (List a) where
     get = do
         size <- Binary.get
@@ -29,9 +40,29 @@
         list & unpack & length & fromIntegral & Word32.Word32 & Binary.put
         list & unpack & mapM_ Binary.put
 
+-- | Allows creating 'List' values with 'Exts.fromList'. Also allows 'List'
+-- literals with the @OverloadedLists@ extension.
+--
+-- >>> [2] :: List Int
+-- fromList [2]
+instance Exts.IsList (List a) where
+    type Item (List a) = a
+
+    fromList items = List items
+
+    toList list = unpack list
+
 instance (DeepSeq.NFData a) => DeepSeq.NFData (List a) where
 
+-- | >>> show ([2] :: List Int)
+-- "fromList [2]"
+instance (Show a) => Show (List a) where
+    show list = "fromList " ++ show (unpack list)
+
 -- | Encoded as a JSON array directly.
+--
+-- >>> Aeson.encode ([2] :: List Int)
+-- "[2]"
 instance (Aeson.ToJSON a) => Aeson.ToJSON (List a) where
     toJSON list = list
         & unpack
diff --git a/library/Octane/Type/Mark.hs b/library/Octane/Type/Mark.hs
--- a/library/Octane/Type/Mark.hs
+++ b/library/Octane/Type/Mark.hs
@@ -22,6 +22,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Fields are stored one after the other in order.
+--
+-- >>> Binary.decode "\x02\x00\x00\x00\x4b\x00\x01\x00\x00\x00" :: Mark
+-- Mark {label = "K", frame = 0x00000001}
+--
+-- >>> Binary.encode (Mark "K" 1)
+-- "\STX\NUL\NUL\NULK\NUL\SOH\NUL\NUL\NUL"
 instance Binary.Binary Mark where
     get = Mark
         <$> Binary.get
diff --git a/library/Octane/Type/Message.hs b/library/Octane/Type/Message.hs
--- a/library/Octane/Type/Message.hs
+++ b/library/Octane/Type/Message.hs
@@ -23,6 +23,12 @@
     } deriving (Eq, Generics.Generic, Show)
 
 -- | Fields stored in order, one after the other.
+--
+-- >>> Binary.decode "\x01\x00\x00\x00\x02\x00\x00\x00\x41\x00\x02\x00\x00\x00\x42\x00" :: Message
+-- Message {frame = 0x00000001, name = "A", content = "B"}
+--
+-- >>> Binary.encode (Message 1 "A" "B")
+-- "\SOH\NUL\NUL\NUL\STX\NUL\NUL\NULA\NUL\STX\NUL\NUL\NULB\NUL"
 instance Binary.Binary Message where
     get = Message
         <$> Binary.get
diff --git a/library/Octane/Type/RemoteId.hs b/library/Octane/Type/RemoteId.hs
--- a/library/Octane/Type/RemoteId.hs
+++ b/library/Octane/Type/RemoteId.hs
@@ -3,47 +3,218 @@
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE StrictData #-}
 
-module Octane.Type.RemoteId (RemoteId(..)) where
+module Octane.Type.RemoteId
+    ( RemoteId(..)
+    , SteamId(..)
+    , PlayStationId(..)
+    , SplitscreenId(..)
+    , XboxId(..)
+    ) where
 
 import Data.Aeson ((.=))
+import Data.Function ((&))
 
 import qualified Control.DeepSeq as DeepSeq
 import qualified Data.Aeson as Aeson
+import qualified Data.Binary.Bits as BinaryBit
+import qualified Data.Binary.Bits.Get as BinaryBit
+import qualified Data.Binary.Bits.Put as BinaryBit
+import qualified Data.ByteString.Lazy as LazyBytes
+import qualified Data.Text as StrictText
+import qualified Data.Text.Encoding as Encoding
 import qualified GHC.Generics as Generics
 import qualified Octane.Type.Text as Text
 import qualified Octane.Type.Word64 as Word64
+import qualified Octane.Utility.Endian as Endian
+import qualified Text.Printf as Printf
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
 -- | A player's canonical remote ID. This is the best way to uniquely identify
--- players.
+-- players
 data RemoteId
-    = SteamId Word64.Word64
-    -- ^ A Steam ID.
-    | PlayStationId Text.Text
-    -- ^ A PlayStation Network ID.
-    | SplitscreenId (Maybe Int)
-    -- ^ A local splitscreen ID.
-    | XboxId Word64.Word64
-    -- ^ An Xbox Live ID.
+    = RemotePlayStationId PlayStationId
+    | RemoteSplitscreenId SplitscreenId
+    | RemoteSteamId SteamId
+    | RemoteXboxId XboxId
     deriving (Eq, Generics.Generic, Show)
 
 instance DeepSeq.NFData RemoteId where
 
+-- | Encodes the remote ID as an object with "Type" and "Value" keys.
+--
+-- >>> Aeson.encode (RemoteSteamId (SteamId 1))
+-- "{\"Value\":1,\"Type\":\"Steam\"}"
 instance Aeson.ToJSON RemoteId where
     toJSON remoteId = case remoteId of
-        PlayStationId x -> Aeson.object
+        RemotePlayStationId x -> Aeson.object
             [ "Type" .= ("PlayStation" :: Text.Text)
-            , "Value" .= x
+            , "Value" .= Aeson.toJSON x
             ]
-        SplitscreenId x -> Aeson.object
+        RemoteSplitscreenId x -> Aeson.object
             [ "Type" .= ("Splitscreen" :: Text.Text)
-            , "Value" .= x
+            , "Value" .= Aeson.toJSON x
             ]
-        SteamId x -> Aeson.object
+        RemoteSteamId x -> Aeson.object
             [ "Type" .= ("Steam" :: Text.Text)
-            , "Value" .= x
+            , "Value" .= Aeson.toJSON x
             ]
-        XboxId x -> Aeson.object
+        RemoteXboxId x -> Aeson.object
             [ "Type" .= ("Xbox" :: Text.Text)
-            , "Value" .= x
+            , "Value" .= Aeson.toJSON x
             ]
+
+
+data PlayStationId = PlayStationId
+    { playStationName :: Text.Text
+    , playStationUnknown :: LazyBytes.ByteString
+    } deriving (Eq, Generics.Generic, Show)
+
+-- | Each part is stored as exactly 16 bits.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x42\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x80" :: PlayStationId
+-- PlayStationId {playStationName = "B", playStationUnknown = "\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\SOH"}
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (PlayStationId "A" "\x01")))
+-- "\130\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\NUL\128"
+instance BinaryBit.BinaryBit PlayStationId where
+    getBits _ = do
+        nameBytes <- BinaryBit.getByteString 16
+        let name = nameBytes
+                & Endian.reverseBitsInStrictBytes
+                & Encoding.decodeLatin1
+                & StrictText.dropWhileEnd (== '\0')
+                & Text.Text
+
+        unknownBytes <- BinaryBit.getByteString 16
+        let unknown = unknownBytes
+                & Endian.reverseBitsInStrictBytes
+                & LazyBytes.fromStrict
+
+        pure (PlayStationId name unknown)
+
+    putBits _ playStationId = do
+        playStationId
+            & playStationName
+            & Text.unpack
+            & StrictText.justifyLeft 16 '\x00'
+            & StrictText.take 16
+            & Text.encodeLatin1
+            & Endian.reverseBitsInStrictBytes
+            & BinaryBit.putByteString
+
+        playStationId
+            & playStationUnknown
+            & LazyBytes.toStrict
+            & Endian.reverseBitsInStrictBytes
+            & BinaryBit.putByteString
+
+instance DeepSeq.NFData PlayStationId where
+
+-- | >>> Aeson.encode (PlayStationId "A" "B")
+-- "{\"Unknown\":\"0x42\",\"Name\":\"A\"}"
+instance Aeson.ToJSON PlayStationId where
+    toJSON playStationId = Aeson.object
+        [ "Name" .= playStationName playStationId
+        , "Unknown" .= (playStationId
+            & playStationUnknown
+            & LazyBytes.unpack
+            & concatMap (Printf.printf "%02x")
+            & ("0x" ++)
+            & StrictText.pack)
+        ]
+
+
+newtype SplitscreenId = SplitscreenId
+    { unpackSplitscreenId :: Maybe Int
+    } deriving (Eq, Generics.Generic, Show)
+
+-- | Stored as a bare byte string.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x00\x00\x00" :: SplitscreenId
+-- SplitscreenId {unpackSplitscreenId = Just 0}
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (SplitscreenId (Just 0))))
+-- "\NUL\NUL\NUL"
+instance BinaryBit.BinaryBit SplitscreenId where
+    getBits _ = do
+        bytes <- BinaryBit.getByteString 3
+        case bytes of
+            "\x00\x00\x00" -> do
+                pure (SplitscreenId (Just 0))
+            _ -> do
+                fail ("Unexpected SplitscreenId value " ++ show bytes)
+
+    putBits _ _splitscreenId = do
+        BinaryBit.putByteString "\x00\x00\x00"
+
+instance DeepSeq.NFData SplitscreenId where
+
+-- | Encoded as an optional number.
+--
+-- >>> Aeson.encode (SplitscreenId Nothing)
+-- "null"
+--
+-- >>> Aeson.encode (SplitscreenId (Just 0))
+-- "0"
+instance Aeson.ToJSON SplitscreenId where
+    toJSON splitscreenId = splitscreenId & unpackSplitscreenId & Aeson.toJSON
+
+
+newtype SteamId = SteamId
+    { unpackSteamId :: Word64.Word64
+    } deriving (Eq, Generics.Generic, Show)
+
+-- | Stored as a plain 'Word64.Word64'.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80\x00\x00\x00\x00\x00\x00\x00" :: SteamId
+-- SteamId {unpackSteamId = 0x0000000000000001}
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (SteamId 1)))
+-- "\128\NUL\NUL\NUL\NUL\NUL\NUL\NUL"
+instance BinaryBit.BinaryBit SteamId where
+    getBits _ = do
+        steamId <- BinaryBit.getBits 0
+        pure (SteamId steamId)
+
+    putBits _ steamId = steamId & unpackSteamId & BinaryBit.putBits 0
+
+instance DeepSeq.NFData SteamId where
+
+-- | Encoded directly as a number.
+--
+-- >>> Aeson.encode (SteamId 1)
+-- "1"
+instance Aeson.ToJSON SteamId where
+    toJSON steamId = steamId & unpackSteamId & Aeson.toJSON
+
+
+newtype XboxId = XboxId
+    { unpackXboxId :: Word64.Word64
+    } deriving (Eq, Generics.Generic, Show)
+
+-- | Stored as a plain 'Word64.Word64'.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80\x00\x00\x00\x00\x00\x00\x00" :: XboxId
+-- XboxId {unpackXboxId = 0x0000000000000001}
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (XboxId 1)))
+-- "\128\NUL\NUL\NUL\NUL\NUL\NUL\NUL"
+instance BinaryBit.BinaryBit XboxId where
+    getBits _ = do
+        xboxId <- BinaryBit.getBits 0
+        pure (XboxId xboxId)
+
+    putBits _ xboxId = xboxId & unpackXboxId & BinaryBit.putBits 0
+
+
+instance DeepSeq.NFData XboxId where
+
+-- | Encoded directly as a number.
+--
+-- >>> Aeson.encode (XboxId 1)
+-- "1"
+instance Aeson.ToJSON XboxId where
+    toJSON xboxId = xboxId & unpackXboxId & Aeson.toJSON
diff --git a/library/Octane/Type/Replication.hs b/library/Octane/Type/Replication.hs
--- a/library/Octane/Type/Replication.hs
+++ b/library/Octane/Type/Replication.hs
@@ -14,6 +14,9 @@
 
 
 -- | A replicated actor in a frame.
+--
+-- This cannot be an instance of 'Data.Binary.Bits.BinaryBit' because it
+-- requires out-of-band information (the class property map) to decode.
 data Replication = Replication
     { actorId :: Word
     -- ^ The actor's ID.
diff --git a/library/Octane/Type/Stream.hs b/library/Octane/Type/Stream.hs
--- a/library/Octane/Type/Stream.hs
+++ b/library/Octane/Type/Stream.hs
@@ -23,6 +23,12 @@
 
 -- | Prefixed by a length in bytes. Each byte is reversed such that
 -- @0b01234567@ is actually @0b76543210@.
+--
+-- >>> Binary.decode "\x01\x00\x00\x00\x0f" :: Stream
+-- Stream {unpack = "1 byte"}
+--
+-- >>> Binary.encode (Stream "\xf0")
+-- "\SOH\NUL\NUL\NUL\SI"
 instance Binary.Binary Stream where
     get = do
         size <- Binary.get
@@ -35,6 +41,10 @@
 
 instance DeepSeq.NFData Stream where
 
+-- | Doesn't show the actual bytes to avoid dumping tons of text.
+--
+-- >>> show (Stream "\xf0")
+-- "Stream {unpack = \"1 byte\"}"
 instance Show Stream where
     show stream = do
         let size = stream & unpack & LazyBytes.length
diff --git a/library/Octane/Type/Text.hs b/library/Octane/Type/Text.hs
--- a/library/Octane/Type/Text.hs
+++ b/library/Octane/Type/Text.hs
@@ -2,7 +2,7 @@
 {-# LANGUAGE DuplicateRecordFields #-}
 {-# LANGUAGE OverloadedStrings #-}
 
-module Octane.Type.Text (Text(..)) where
+module Octane.Type.Text (Text(..), encodeLatin1) where
 
 import Data.Function ((&))
 
@@ -23,13 +23,23 @@
 import qualified Octane.Type.Int32 as Int32
 import qualified Octane.Utility.Endian as Endian
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A thin wrapper around 'StrictText.Text'.
 newtype Text = Text
     { unpack :: StrictText.Text
     } deriving (Eq, Generics.Generic, Ord)
 
 -- | Text is both length-prefixed and null-terminated.
+--
+-- >>> Binary.decode "\x02\x00\x00\x00\x4b\x00" :: Text
+-- "K"
+--
+-- >>> Binary.encode ("K" :: Text)
+-- "\STX\NUL\NUL\NULK\NUL"
 instance Binary.Binary Text where
     get = getText
         Binary.get
@@ -44,6 +54,12 @@
 
 -- | Both length-prefixed and null-terminated. The bits in each byte are
 -- reversed.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x40\x00\x00\x00\xd2\x00" :: Text
+-- "K"
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 ("K" :: Text)))
+-- "@\NUL\NUL\NUL\210\NUL"
 instance BinaryBit.BinaryBit Text where
     getBits _ = getText
         (BinaryBit.getBits 32)
@@ -58,23 +74,37 @@
 
 -- | Allows you to write 'Text' as string literals with @OverloadedStrings@.
 -- Also allows using the 'String.fromString' helper function.
+--
+-- >>> "K" :: Text
+-- "K"
 instance String.IsString Text where
     fromString string = Text (StrictText.pack string)
 
 instance DeepSeq.NFData Text where
 
 -- | Shown as a string literal, like @"this"@.
+--
+-- >>> show ("K" :: Text)
+-- "\"K\""
 instance Show Text where
     show text = show (unpack text)
 
 -- | Encoded directly as a JSON string.
+--
+-- >>> Aeson.encode ("K" :: Text)
+-- "\"K\""
 instance Aeson.ToJSON Text where
     toJSON text = text
         & unpack
         & Aeson.toJSON
 
 
-getText :: (Monad m) => (m Int32.Int32) -> (Int -> m StrictBytes.ByteString) -> (StrictBytes.ByteString -> StrictBytes.ByteString) -> m Text
+getText
+    :: (Monad m)
+    => (m Int32.Int32)
+    -> (Int -> m StrictBytes.ByteString)
+    -> (StrictBytes.ByteString -> StrictBytes.ByteString)
+    -> m Text
 getText getInt getBytes convertBytes = do
     (Int32.Int32 rawSize) <- getInt
     (size, decode) <-
@@ -101,7 +131,13 @@
         _ -> fail ("Unexpected Text value " ++ show rawText)
 
 
-putText :: (Monad m) => (Int32.Int32 -> m ()) -> (StrictBytes.ByteString -> m ()) -> (StrictBytes.ByteString -> StrictBytes.ByteString) -> Text -> m ()
+putText
+    :: (Monad m)
+    => (Int32.Int32 -> m ())
+    -> (StrictBytes.ByteString -> m ())
+    -> (StrictBytes.ByteString -> StrictBytes.ByteString)
+    -> Text
+    -> m ()
 putText putInt putBytes convertBytes text = do
     let fullText = text & unpack & flip StrictText.snoc '\NUL'
     let size = fullText & StrictText.length & fromIntegral
@@ -114,6 +150,11 @@
         fullText & Encoding.encodeUtf16LE & convertBytes & putBytes
 
 
+-- | Encodes text as Latin-1. Note that this isn't really safe if the text has
+-- characters that can't be encoded in Latin-1.
+--
+-- >>> encodeLatin1 "A"
+-- "A"
 encodeLatin1 :: StrictText.Text -> StrictBytes.ByteString
 encodeLatin1 text = text
     & StrictText.unpack
diff --git a/library/Octane/Type/Value.hs b/library/Octane/Type/Value.hs
--- a/library/Octane/Type/Value.hs
+++ b/library/Octane/Type/Value.hs
@@ -65,10 +65,7 @@
         Word32.Word32
         (Maybe Word32.Word32)
     | VLoadoutOnline
-        Word32.Word32
-        Word32.Word32
-        Word32.Word32
-        (Maybe Word8.Word8)
+        [[(Word32.Word32, Int)]]
     | VLocation
         (Vector.Vector Int)
     | VMusicStinger
diff --git a/library/Octane/Type/Vector.hs b/library/Octane/Type/Vector.hs
--- a/library/Octane/Type/Vector.hs
+++ b/library/Octane/Type/Vector.hs
@@ -11,6 +11,10 @@
 
 -- | Three values packed together. Although the fields are called @x@, @y@, and
 -- @z@, that may not be what they actually represent.
+--
+-- This cannot be an instance of 'Data.Binary.Bits.BinaryBit' because it is not
+-- always serialized the same way. Sometimes it is three values run together,
+-- but other times it has a flag for the presence of each value.
 data Vector a = Vector
     { x :: a
     , y :: a
@@ -20,6 +24,9 @@
 instance (DeepSeq.NFData a) => DeepSeq.NFData (Vector a) where
 
 -- | Encoded as a JSON array with 3 elements.
+--
+-- Aeson.encode (Vector 1 2 3 :: Vector Int)
+-- "[1,2,3]"
 instance (Aeson.ToJSON a) => Aeson.ToJSON (Vector a) where
     toJSON vector = Aeson.toJSON
         [ x vector
diff --git a/library/Octane/Type/Word16.hs b/library/Octane/Type/Word16.hs
--- a/library/Octane/Type/Word16.hs
+++ b/library/Octane/Type/Word16.hs
@@ -21,7 +21,11 @@
     { unpack :: Word.Word16
     } deriving (Eq, Generics.Generic, Num, Ord)
 
--- | Stored in little-endian byte order.
+-- | >>> Binary.decode "\x01\x00" :: Word16
+-- 0x0001
+--
+-- >>> Binary.encode (1 :: Word16)
+-- "\SOH\NUL"
 instance Binary.Binary Word16 where
     get = do
         value <- Binary.getWord16le
@@ -34,10 +38,16 @@
 instance DeepSeq.NFData Word16 where
 
 -- | Shown as @0x0102@.
+--
+-- >>> show (1 :: Word16)
+-- "0x0001"
 instance Show Word16 where
     show word16 = Printf.printf "0x%04x" (unpack word16)
 
 -- | Encoded as a JSON number.
+--
+-- >>> Aeson.encode (1 :: Word16)
+-- "1"
 instance Aeson.ToJSON Word16 where
     toJSON word16 = word16
         & unpack
diff --git a/library/Octane/Type/Word32.hs b/library/Octane/Type/Word32.hs
--- a/library/Octane/Type/Word32.hs
+++ b/library/Octane/Type/Word32.hs
@@ -20,13 +20,21 @@
 import qualified Octane.Utility.Endian as Endian
 import qualified Text.Printf as Printf
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A 32-bit unsigned integer.
 newtype Word32 = Word32
     { unpack :: Word.Word32
     } deriving (Eq, Generics.Generic, Num, Ord)
 
--- | Stored in little-endian byte order.
+-- | >>> Binary.decode "\x01\x00\x00\x00" :: Word32
+-- 0x00000001
+--
+-- >>> Binary.encode (1 :: Word32)
+-- "\SOH\NUL\NUL\NUL"
 instance Binary.Binary Word32 where
     get = do
         value <- Binary.getWord32le
@@ -36,7 +44,11 @@
         let value = unpack word32
         Binary.putWord32le value
 
--- | Little-endian with the bits in each byte reversed.
+-- | >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80\x00\x00\x00" :: Word32
+-- 0x00000001
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1 :: Word32)))
+-- "\128\NUL\NUL\NUL"
 instance BinaryBit.BinaryBit Word32 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 4
@@ -56,10 +68,16 @@
 instance DeepSeq.NFData Word32 where
 
 -- | Shown as @0x01020304@.
+--
+-- >>> show (1 :: Word32)
+-- "0x00000001"
 instance Show Word32 where
     show word32 = Printf.printf "0x%08x" (unpack word32)
 
 -- | Encoded as a JSON number.
+--
+-- >>> Aeson.encode (1 :: Word32)
+-- "1"
 instance Aeson.ToJSON Word32 where
     toJSON word32 = word32
         & unpack
diff --git a/library/Octane/Type/Word64.hs b/library/Octane/Type/Word64.hs
--- a/library/Octane/Type/Word64.hs
+++ b/library/Octane/Type/Word64.hs
@@ -20,13 +20,21 @@
 import qualified Octane.Utility.Endian as Endian
 import qualified Text.Printf as Printf
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A 64-bit unsigned integer.
 newtype Word64 = Word64
     { unpack :: Word.Word64
     } deriving (Eq, Generics.Generic, Num, Ord)
 
--- | Stored in little-endian byte order.
+-- | >>> Binary.decode "\x01\x00\x00\x00\x00\x00\x00\x00" :: Word64
+-- 0x0000000000000001
+--
+-- >>> Binary.encode (1 :: Word64)
+-- "\SOH\NUL\NUL\NUL\NUL\NUL\NUL\NUL"
 instance Binary.Binary Word64 where
     get = do
         value <- Binary.getWord64le
@@ -36,7 +44,11 @@
         let value = unpack word64
         Binary.putWord64le value
 
--- | Little-endian with the bits in each byte reversed.
+-- | >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80\x00\x00\x00\x00\x00\x00\x00" :: Word64
+-- 0x0000000000000001
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1 :: Word64)))
+-- "\128\NUL\NUL\NUL\NUL\NUL\NUL\NUL"
 instance BinaryBit.BinaryBit Word64 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 8
@@ -55,11 +67,17 @@
 
 instance DeepSeq.NFData Word64 where
 
--- | Shown as @0x01020304@.
+-- | Shown as @0x0102030405060708@.
+--
+-- >>> show (1 :: Word64)
+-- "0x0000000000000001"
 instance Show Word64 where
     show word64 = Printf.printf "0x%016x" (unpack word64)
 
 -- | Encoded as a JSON number.
+--
+-- >>> Aeson.encode (1 :: Word64)
+-- "1"
 instance Aeson.ToJSON Word64 where
     toJSON word64 = word64
         & unpack
diff --git a/library/Octane/Type/Word8.hs b/library/Octane/Type/Word8.hs
--- a/library/Octane/Type/Word8.hs
+++ b/library/Octane/Type/Word8.hs
@@ -20,12 +20,21 @@
 import qualified Octane.Utility.Endian as Endian
 import qualified Text.Printf as Printf
 
+-- $setup
+-- >>> import qualified Data.Binary.Get as Binary
+-- >>> import qualified Data.Binary.Put as Binary
 
+
 -- | A 8-bit unsigned integer.
 newtype Word8 = Word8
     { unpack :: Word.Word8
     } deriving (Eq, Generics.Generic, Num, Ord)
 
+-- | >>> Binary.decode "\x01" :: Word8
+-- 0x01
+--
+-- >>> Binary.encode (1 :: Word8)
+-- "\SOH"
 instance Binary.Binary Word8 where
     get = do
         value <- Binary.getWord8
@@ -36,6 +45,12 @@
         Binary.putWord8 value
 
 -- | The bits are reversed.
+--
+-- >>> Binary.runGet (BinaryBit.runBitGet (BinaryBit.getBits 0)) "\x80" :: Word8
+-- 0x01
+--
+-- >>> Binary.runPut (BinaryBit.runBitPut (BinaryBit.putBits 0 (1 :: Word8)))
+-- "\128"
 instance BinaryBit.BinaryBit Word8 where
     getBits _ = do
         bytes <- BinaryBit.getByteString 1
@@ -55,10 +70,16 @@
 instance DeepSeq.NFData Word8 where
 
 -- | Shown as @0x01@.
+--
+-- >>> show (1 :: Word8)
+-- "0x01"
 instance Show Word8 where
     show word8 = Printf.printf "0x%02x" (unpack word8)
 
 -- | Encoded as a JSON number.
+--
+-- >>> Aeson.encode (1 :: Word8)
+-- "1"
 instance Aeson.ToJSON Word8 where
     toJSON word8 = word8
         & unpack
diff --git a/library/Octane/Utility.hs b/library/Octane/Utility.hs
--- a/library/Octane/Utility.hs
+++ b/library/Octane/Utility.hs
@@ -1,11 +1,15 @@
 module Octane.Utility
     ( module Octane.Utility.ClassPropertyMap
     , module Octane.Utility.CRC
+    , module Octane.Utility.Embed
     , module Octane.Utility.Endian
     , module Octane.Utility.Optimizer
+    , module Octane.Utility.Parser
     ) where
 
 import Octane.Utility.ClassPropertyMap
 import Octane.Utility.CRC
+import Octane.Utility.Embed
 import Octane.Utility.Endian
 import Octane.Utility.Optimizer
+import Octane.Utility.Parser
diff --git a/library/Octane/Utility/CRC.hs b/library/Octane/Utility/CRC.hs
--- a/library/Octane/Utility/CRC.hs
+++ b/library/Octane/Utility/CRC.hs
@@ -9,10 +9,10 @@
 -- | Computes the CRC32 of some bytes. Note that this is a non-standard CRC32.
 -- It probably only works for Rocket League.
 --
--- >>> crc32 LazyBytes.empty -- ""
+-- >>> crc32 ""
 -- 4023120385
 --
--- >>> crc32 (LazyBytes.pack [49 .. 57]) -- "123456789"
+-- >>> crc32 "123456789"
 -- 3690624627
 crc32 :: LazyBytes.ByteString -> Word.Word32
 crc32 bytes = do
diff --git a/library/Octane/Utility/ClassPropertyMap.hs b/library/Octane/Utility/ClassPropertyMap.hs
--- a/library/Octane/Utility/ClassPropertyMap.hs
+++ b/library/Octane/Utility/ClassPropertyMap.hs
@@ -10,7 +10,6 @@
 
 import Data.Function ((&))
 
-import qualified Data.Char as Char
 import qualified Data.IntMap.Strict as IntMap
 import qualified Data.List as List
 import qualified Data.Map.Strict as Map
@@ -23,6 +22,7 @@
 import qualified Octane.Type.ReplayWithoutFrames as ReplayWithoutFrames
 import qualified Octane.Type.Text as Text
 import qualified Octane.Type.Word32 as Word32
+import qualified Text.Regex as Regex
 
 
 -- | The class property map is a map from class IDs in the stream to a map from
@@ -169,21 +169,55 @@
 
 -- | Gets the class ID and name for a given property ID.
 getClass
-    :: IntMap.IntMap StrictText.Text -- ^ Property ID to property name
+    :: (Monad m)
+    => IntMap.IntMap StrictText.Text -- ^ Property ID to property name
     -> Map.Map StrictText.Text StrictText.Text -- ^ Property name to class name
     -> Map.Map StrictText.Text Int -- ^ Class name to class ID
     -> Int -- ^ property ID
-    -> Maybe (Int, StrictText.Text) -- ^ Maybe class ID and class name
-getClass propertyIdsToNames propertyNamesToClassNames classNamesToIds propertyId =
-    case IntMap.lookup propertyId propertyIdsToNames of
-        Nothing -> Nothing
-        Just rawPropertyName -> let
-            -- There are a large number of properties that end in numbers that
-            -- should all be treated the same. Instead of explicitly mapping
-            -- each of them, we can remove the numbers and treat them the same.
-            propertyName = rawPropertyName & StrictText.dropWhileEnd Char.isDigit
-            in case Map.lookup propertyName propertyNamesToClassNames of
-                Nothing -> Nothing
-                Just className -> case Map.lookup className classNamesToIds of
-                    Nothing -> Nothing
-                    Just classId -> Just (classId, className)
+    -> m (Int, StrictText.Text) -- ^ Maybe class ID and class name
+getClass propertyIdsToNames propertyNamesToClassNames classNamesToIds propertyId = do
+    rawPropertyName <- getPropertyName propertyIdsToNames propertyId
+    let propertyName = normalizeName rawPropertyName
+    className <- getClassName propertyNamesToClassNames propertyName
+    classId <- getClassId classNamesToIds className
+    pure (classId, className)
+
+
+getPropertyName :: (Monad m) => IntMap.IntMap StrictText.Text -> Int -> m StrictText.Text
+getPropertyName propertyNames propertyId = do
+    case IntMap.lookup propertyId propertyNames of
+        Nothing -> do
+            fail ("Could not find name for property " ++ show propertyId)
+        Just propertyName -> do
+            pure propertyName
+
+
+normalizeName :: StrictText.Text -> StrictText.Text
+normalizeName name = name
+    & StrictText.unpack
+    & replace "_[0-9]+$" ""
+    & replace "^[A-Z_a-z]+[.]TheWorld:" "TheWorld:"
+    & StrictText.pack
+
+
+replace :: String -> String -> String -> String
+replace pattern replacement input =
+    Regex.subRegex (Regex.mkRegex pattern) input replacement
+
+
+getClassName :: (Monad m) => Map.Map StrictText.Text StrictText.Text -> StrictText.Text -> m StrictText.Text
+getClassName classNames propertyName = do
+    case Map.lookup propertyName classNames of
+        Nothing -> do
+            fail ("Could not find class for property " ++ show propertyName)
+        Just className -> do
+            pure className
+
+
+getClassId :: (Monad m) => Map.Map StrictText.Text Int -> StrictText.Text -> m Int
+getClassId classIds className = do
+    case Map.lookup className classIds of
+        Nothing -> do
+            fail ("Could not find ID for class " ++ show className)
+        Just classId -> do
+            pure classId
diff --git a/library/Octane/Utility/Embed.hs b/library/Octane/Utility/Embed.hs
new file mode 100644
--- /dev/null
+++ b/library/Octane/Utility/Embed.hs
@@ -0,0 +1,58 @@
+{-# LANGUAGE FlexibleContexts #-}
+
+-- | These helper functions are usually used with 'Data.FileEmbed.embedFile'.
+module Octane.Utility.Embed (decodeBimap, decodeMap, decodeSet) where
+
+import Data.Function ((&))
+
+import qualified Data.Aeson as Aeson
+import qualified Data.Bimap as Bimap
+import qualified Data.ByteString as StrictBytes
+import qualified Data.Map.Strict as Map
+import qualified Data.Maybe as Maybe
+import qualified Data.Set as Set
+
+
+-- | Decodes some bytes into a bidirection map. The bytes are assumed to be a
+-- JSON object mapping values to keys. That means the resulting bimap is
+-- 'Bimap.twist'ed from what you might expect.
+--
+-- >>> decodeBimap "{ \"value\": \"key\" }" :: Bimap.Bimap String String
+-- fromList [("key","value")]
+decodeBimap
+    :: (Aeson.FromJSON (Map.Map b a), Ord a, Ord b)
+    => StrictBytes.ByteString
+    -> Bimap.Bimap a b
+decodeBimap bytes = bytes
+    & Aeson.decodeStrict
+    & Maybe.fromMaybe Map.empty
+    & Map.toList
+    & Bimap.fromList
+    & Bimap.twist
+
+
+-- | Decodes some bytes into a map. The bytes are assumed to be a JSON object
+-- mapping keys to values.
+--
+-- >>> decodeMap "{ \"key\": \"value\" }" :: Map.Map String String
+-- fromList [("key","value")]
+decodeMap
+    :: (Aeson.FromJSON (Map.Map a b))
+    => StrictBytes.ByteString
+    -> Map.Map a b
+decodeMap bytes = bytes
+    & Aeson.decodeStrict
+    & Maybe.fromMaybe Map.empty
+
+
+-- | Decodes some bytes into a set. The bytes are assumed to be a JSON array.
+--
+-- >>> decodeSet "[\"element\"]" :: Set.Set String
+-- fromList ["element"]
+decodeSet
+    :: (Aeson.FromJSON a, Ord a)
+    => StrictBytes.ByteString
+    -> Set.Set a
+decodeSet bytes = bytes
+    & Aeson.decodeStrict
+    & Maybe.fromMaybe Set.empty
diff --git a/library/Octane/Utility/Endian.hs b/library/Octane/Utility/Endian.hs
--- a/library/Octane/Utility/Endian.hs
+++ b/library/Octane/Utility/Endian.hs
@@ -12,11 +12,17 @@
 
 
 -- | Reverses all the bits in each lazy byte.
+--
+-- >>> reverseBitsInLazyBytes "\x01"
+-- "\128"
 reverseBitsInLazyBytes :: LazyBytes.ByteString -> LazyBytes.ByteString
 reverseBitsInLazyBytes bytes = LazyBytes.map reverseBits bytes
 
 
 -- | Reverses all the bits in each strict byte.
+--
+-- >>> reverseBitsInStrictBytes "\x01"
+-- "\128"
 reverseBitsInStrictBytes :: StrictBytes.ByteString -> StrictBytes.ByteString
 reverseBitsInStrictBytes bytes = StrictBytes.map reverseBits bytes
 
diff --git a/library/Octane/Utility/Parser.hs b/library/Octane/Utility/Parser.hs
--- a/library/Octane/Utility/Parser.hs
+++ b/library/Octane/Utility/Parser.hs
@@ -12,8 +12,6 @@
 import qualified Data.Binary.Bits.Get as Bits
 import qualified Data.Binary.Get as Binary
 import qualified Data.Bits as Bits
-import qualified Data.ByteString as StrictBytes
-import qualified Data.ByteString.Lazy as LazyBytes
 import qualified Data.IntMap.Strict as IntMap
 import qualified Data.Map.Strict as Map
 import qualified Data.Set as Set
@@ -44,7 +42,6 @@
 import qualified Octane.Type.Word64 as Word64
 import qualified Octane.Type.Word8 as Word8
 import qualified Octane.Utility.ClassPropertyMap as CPM
-import qualified Octane.Utility.Endian as Endian
 import qualified Text.Printf as Printf
 
 
@@ -163,7 +160,7 @@
     objectName <- case context & contextObjectMap & IntMap.lookup (Int32.fromInt32 objectId) of
         Nothing -> fail ("could not find object name for id " ++ show objectId)
         Just x -> pure x
-    (classId, className) <- case CPM.getClass (contextObjectMap context) Data.objectToClass (contextClassMap context) (Int32.fromInt32 objectId) of
+    (classId, className) <- case CPM.getClass (contextObjectMap context) Data.classes (contextClassMap context) (Int32.fromInt32 objectId) of
         Nothing -> fail ("could not find class for object id " ++ show objectId)
         Just x -> pure x
     classInit <- getInitialization className
@@ -195,7 +192,7 @@
                        -> Bits.BitGet (Context, Replication.Replication)
 getExistingReplication context actorId = do
     thing <- case context & contextThings & IntMap.lookup actorId of
-        Nothing -> fail ("could not find thing for actor id " ++ show actorId)
+        Nothing -> fail ("could not find thing for existing actor " ++ show actorId)
         Just x -> pure x
     props <- getProps context thing
     pure (context, Replication.Replication
@@ -213,7 +210,7 @@
                      -> Bits.BitGet (Context, Replication.Replication)
 getClosedReplication context actorId = do
     thing <- case context & contextThings & IntMap.lookup actorId of
-        Nothing -> fail ("could not find thing for actor id " ++ show actorId)
+        Nothing -> fail ("could not find thing for closed actor " ++ show actorId)
         Just x -> pure x
     let newThings = context & contextThings & IntMap.delete actorId
     let newContext = context { contextThings = newThings }
@@ -266,40 +263,35 @@
 
 
 getPropValue :: Context -> StrictText.Text -> Bits.BitGet Value.Value
-getPropValue context name = case Map.lookup name (propertyNameToGet context) of
-    Nothing -> fail ("don't know how to read property " ++ show name)
-    Just get -> get
-
-
-propertyNameToGet :: Context -> Map.Map StrictText.Text (Bits.BitGet Value.Value)
-propertyNameToGet context =
-    [ (Data.booleanProperties, getBooleanProperty)
-    , (Data.byteProperties, getByteProperty)
-    , (Data.camSettingsProperties, getCamSettingsProperty)
-    , (Data.demolishProperties, getDemolishProperty)
-    , (Data.enumProperties, getEnumProperty)
-    , (Data.explosionProperties, getExplosionProperty)
-    , (Data.flaggedIntProperties, getFlaggedIntProperty)
-    , (Data.floatProperties, getFloatProperty)
-    , (Data.gameModeProperties, getGameModeProperty)
-    , (Data.intProperties, getIntProperty)
-    , (Data.loadoutOnlineProperties, getLoadoutOnlineProperty)
-    , (Data.loadoutProperties, getLoadoutProperty)
-    , (Data.locationProperties, getLocationProperty)
-    , (Data.musicStingerProperties, getMusicStingerProperty)
-    , (Data.pickupProperties, getPickupProperty)
-    , (Data.privateMatchSettingsProperties, getPrivateMatchSettingsProperty)
-    , (Data.qWordProperties, getQWordProperty)
-    , (Data.relativeRotationProperties, getRelativeRotationProperty)
-    , (Data.reservationProperties, getReservationProperty context)
-    , (Data.rigidBodyStateProperties, getRigidBodyStateProperty)
-    , (Data.stringProperties, getStringProperty)
-    , (Data.teamPaintProperties, getTeamPaintProperty)
-    , (Data.uniqueIdProperties, getUniqueIdProperty)
-    , (Set.fromList [StrictText.pack "TAGame.PRI_TA:PartyLeader"], getPartyLeaderProperty)
-    ]
-        & concatMap (\ (ks, v) -> ks & Set.toList & map (\ k -> (k, v)))
-        & Map.fromList
+getPropValue context name = case Map.lookup name Data.properties of
+    Just property -> case StrictText.unpack property of
+        "boolean" -> getBooleanProperty
+        "byte" -> getByteProperty
+        "cam_settings" -> getCamSettingsProperty
+        "demolish" -> getDemolishProperty
+        "enum" -> getEnumProperty
+        "explosion" -> getExplosionProperty
+        "flagged_int" -> getFlaggedIntProperty
+        "float" -> getFloatProperty
+        "game_mode" -> getGameModeProperty
+        "int" -> getIntProperty
+        "loadout_online" -> getLoadoutOnlineProperty
+        "loadout" -> getLoadoutProperty
+        "location" -> getLocationProperty
+        "music_stinger" -> getMusicStingerProperty
+        "party_leader" -> getPartyLeaderProperty
+        "pickup" -> getPickupProperty
+        "private_match_settings" -> getPrivateMatchSettingsProperty
+        "qword" -> getQWordProperty
+        "relative_rotation" -> getRelativeRotationProperty
+        "reservation" -> getReservationProperty context
+        "rigid_body_state" -> getRigidBodyStateProperty
+        "string" -> getStringProperty
+        "team_paint" -> getTeamPaintProperty
+        "unique_id" -> getUniqueIdProperty
+        _ -> fail ("Don't know how to read property type " ++ show property ++ " for " ++ show name)
+    Nothing -> do
+        fail ("Don't know how to read property " ++ show name)
 
 
 getBooleanProperty :: Bits.BitGet Value.Value
@@ -382,24 +374,14 @@
 
 getLoadoutOnlineProperty :: Bits.BitGet Value.Value
 getLoadoutOnlineProperty = do
-    version <- getWord32
-    x <- getWord32
-    y <- getWord32
-    z <- if version >= 12
-        then do
-            value <- getWord8
-
-            -- After the Neo Tokyo update, online loadouts could have this
-            -- ridiculously high "version". I think it means the player is
-            -- using an item with an unusual color.
-            Monad.when (version == 0x0100000c) (do
-                unknown <- Bits.getWord64be 37
-                Monad.when (unknown /= 0) (do
-                    fail (Printf.printf "Read 37 online loadout bits and they weren't all 0! 0b%037b" unknown)))
-
-            pure (Just value)
-        else pure Nothing
-    pure (Value.VLoadoutOnline version x y z)
+    size <- getWord8
+    values <- Monad.replicateM (size & Word8.unpack & fromIntegral) (do
+        innerSize <- getWord8
+        Monad.replicateM (innerSize & Word8.unpack & fromIntegral) (do
+            x <- getWord32
+            y <- getInt 27
+            pure (x, y)))
+    pure (Value.VLoadoutOnline values)
 
 
 getLoadoutProperty :: Bits.BitGet Value.Value
@@ -537,7 +519,7 @@
 getPartyLeaderProperty = do
     systemId <- getSystemId
     (remoteId, localId) <- if systemId == 0
-        then pure (RemoteId.SplitscreenId Nothing, Nothing)
+        then pure (RemoteId.RemoteSplitscreenId (RemoteId.SplitscreenId Nothing), Nothing)
         else do
             remoteId <- getRemoteId systemId
             localId <- getLocalId
@@ -546,11 +528,11 @@
 
 
 getFloat32 :: Bits.BitGet Float32.Float32
-getFloat32 = BinaryBit.getBits unimportant
+getFloat32 = BinaryBit.getBits 0
 
 
 getText :: Bits.BitGet Text.Text
-getText = BinaryBit.getBits unimportant
+getText = BinaryBit.getBits 0
 
 
 getUniqueId :: Bits.BitGet (Word8.Word8, RemoteId.RemoteId, Maybe Word8.Word8)
@@ -568,32 +550,17 @@
 getRemoteId :: Word8.Word8 -> Bits.BitGet RemoteId.RemoteId
 getRemoteId systemId = case systemId of
     0 -> do
-        remoteId <- Bits.getByteString 3
-        if StrictBytes.all (\ byte -> byte == 0) remoteId
-            then 0 & Just & RemoteId.SplitscreenId & pure
-            else fail ("unexpected splitscreen id " ++ show remoteId)
+        splitscreenId <- BinaryBit.getBits 0
+        pure (RemoteId.RemoteSplitscreenId splitscreenId)
     1 -> do
-        bytes <- Bits.getByteString 8
-        let remoteId = Binary.runGet
-                Binary.getWord64le
-                (bytes & LazyBytes.fromStrict & Endian.reverseBitsInLazyBytes)
-        remoteId & Word64.toWord64 & RemoteId.SteamId & pure
+        steamId <- BinaryBit.getBits 0
+        pure (RemoteId.RemoteSteamId steamId)
     2 -> do
-        bytes <- Bits.getByteString 32
-        let remoteId = bytes
-                & LazyBytes.fromStrict
-                & Endian.reverseBitsInLazyBytes
-                & LazyBytes.unpack
-                & concatMap (\ b -> Printf.printf "%02x" b)
-                & StrictText.pack
-                & Text.Text
-        remoteId & RemoteId.PlayStationId & pure
+        playStationId <- BinaryBit.getBits 0
+        pure (RemoteId.RemotePlayStationId playStationId)
     4 -> do
-        bytes <- Bits.getByteString 8
-        let remoteId = Binary.runGet
-                Binary.getWord64le
-                (bytes & LazyBytes.fromStrict & Endian.reverseBitsInLazyBytes)
-        remoteId & Word64.toWord64 & RemoteId.XboxId & pure
+        xboxId <- BinaryBit.getBits 0
+        pure (RemoteId.RemoteXboxId xboxId)
     _ -> fail ("unknown system id " ++ show systemId)
 
 
@@ -721,13 +688,13 @@
 getInitialization :: StrictText.Text -> Bits.BitGet Initialization.Initialization
 getInitialization className = do
     location <-
-        if Set.member className Data.locationClasses
+        if Set.member className Data.classesWithLocation
             then do
                 vector <- getVector
                 pure (Just vector)
             else pure Nothing
     rotation <-
-        if Set.member className Data.rotationClasses
+        if Set.member className Data.classesWithRotation
             then do
                 vector <- getVectorBytewise
                 pure (Just vector)
@@ -773,23 +740,23 @@
 
 
 getInt32 :: Bits.BitGet Int32.Int32
-getInt32 = BinaryBit.getBits unimportant
+getInt32 = BinaryBit.getBits 0
 
 
 getInt8 :: Bits.BitGet Int8.Int8
-getInt8 = BinaryBit.getBits unimportant
+getInt8 = BinaryBit.getBits 0
 
 
 getWord64 :: Bits.BitGet Word64.Word64
-getWord64 = BinaryBit.getBits unimportant
+getWord64 = BinaryBit.getBits 0
 
 
 getWord32 :: Bits.BitGet Word32.Word32
-getWord32 = BinaryBit.getBits unimportant
+getWord32 = BinaryBit.getBits 0
 
 
 getWord8 :: Bits.BitGet Word8.Word8
-getWord8 = BinaryBit.getBits unimportant
+getWord8 = BinaryBit.getBits 0
 
 
 getActorId :: Bits.BitGet Int
@@ -805,10 +772,4 @@
 
 
 getBool :: Bits.BitGet Boolean.Boolean
-getBool = BinaryBit.getBits unimportant
-
-
--- | The 'getBits' function from "Data.Binary.Bits" requires a size parameter.
--- None of Octane's instances use it.
-unimportant :: Int
-unimportant = 0
+getBool = BinaryBit.getBits 0
diff --git a/octane.cabal b/octane.cabal
--- a/octane.cabal
+++ b/octane.cabal
@@ -3,7 +3,7 @@
 -- see: https://github.com/sol/hpack
 
 name:           octane
-version:        0.12.0
+version:        0.13.0
 synopsis:       Parse Rocket League replays.
 description:    Octane parses Rocket League replays.
 category:       Game
@@ -17,6 +17,12 @@
 
 extra-source-files:
     CHANGELOG.markdown
+    data/classes-with-location.json
+    data/classes-with-rotation.json
+    data/classes.json
+    data/game-modes.json
+    data/products.json
+    data/properties.json
     package.yaml
     README.markdown
     stack.yaml
@@ -39,26 +45,16 @@
     , containers ==0.5.*
     , data-binary-ieee754 ==0.4.*
     , deepseq ==1.4.*
+    , file-embed ==0.0.*
     , http-client ==0.4.*
     , http-client-tls ==0.2.*
+    , regex-compat ==0.95.*
     , text ==1.2.*
     , unordered-containers ==0.2.*
     , vector ==0.11.*
   exposed-modules:
       Octane
       Octane.Data
-      Octane.Data.Antennas
-      Octane.Data.Bodies
-      Octane.Data.Bots
-      Octane.Data.Classes
-      Octane.Data.Decals
-      Octane.Data.Finishes
-      Octane.Data.GameModes
-      Octane.Data.Logos
-      Octane.Data.Properties
-      Octane.Data.RocketTrails
-      Octane.Data.Toppers
-      Octane.Data.Wheels
       Octane.Main
       Octane.Type
       Octane.Type.Boolean
@@ -95,6 +91,7 @@
       Octane.Utility
       Octane.Utility.ClassPropertyMap
       Octane.Utility.CRC
+      Octane.Utility.Embed
       Octane.Utility.Endian
       Octane.Utility.Optimizer
       Octane.Utility.Parser
diff --git a/package.yaml b/package.yaml
--- a/package.yaml
+++ b/package.yaml
@@ -25,6 +25,7 @@
     source-dirs: executable
 extra-source-files:
 - CHANGELOG.markdown
+- data/*.json
 - package.yaml
 - README.markdown
 - stack.yaml
@@ -41,8 +42,10 @@
   - containers ==0.5.*
   - data-binary-ieee754 ==0.4.*
   - deepseq ==1.4.*
+  - file-embed ==0.0.*
   - http-client ==0.4.*
   - http-client-tls ==0.2.*
+  - regex-compat ==0.95.*
   - text ==1.2.*
   - unordered-containers ==0.2.*
   - vector ==0.11.*
@@ -76,4 +79,4 @@
     - -with-rtsopts=-N
     main: Main.hs
     source-dirs: test-suite
-version: '0.12.0'
+version: '0.13.0'
